From 74b52cec0370b0ae49606aea023861758e54fea5 Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 13 Jun 2026 10:11:43 +0200 Subject: [PATCH 01/39] feat: add recursive schemas --- library/src/methods/flatten/flatten.test-d.ts | 69 +++++ library/src/schemas/index.ts | 1 + library/src/schemas/recursive/index.ts | 6 + .../src/schemas/recursive/recursive.test-d.ts | 270 ++++++++++++++++++ .../src/schemas/recursive/recursive.test.ts | 168 +++++++++++ library/src/schemas/recursive/recursive.ts | 226 +++++++++++++++ .../recursive/recursiveAsync.test-d.ts | 173 +++++++++++ .../schemas/recursive/recursiveAsync.test.ts | 129 +++++++++ .../src/schemas/recursive/recursiveAsync.ts | 94 ++++++ library/src/types/issue.ts | 17 +- .../src/utils/getDotPath/getDotPath.test-d.ts | 61 ++++ packages/to-json-schema/README.md | 1 + .../convertSchema/convertSchema.test.ts | 134 +++++++++ .../converters/convertSchema/convertSchema.ts | 49 ++++ .../toJsonSchema/toJsonSchema.test.ts | 62 ++++ .../toJsonSchemaDefs/toJsonSchemaDefs.test.ts | 23 ++ .../routes/api/(async)/lazyAsync/index.mdx | 8 +- .../api/(async)/recursiveAsync/index.mdx | 89 ++++++ .../api/(async)/recursiveAsync/properties.ts | 80 ++++++ .../src/routes/api/(schemas)/lazy/index.mdx | 12 +- .../routes/api/(schemas)/recursive/index.mdx | 109 +++++++ .../api/(schemas)/recursive/properties.ts | 53 ++++ .../(types)/GenericRecursiveSchema/index.mdx | 20 ++ .../GenericRecursiveSchema/properties.ts | 63 ++++ .../api/(types)/RecursiveSchema/index.mdx | 25 ++ .../api/(types)/RecursiveSchema/properties.ts | 115 ++++++++ .../(types)/RecursiveSchemaAsync/index.mdx | 25 ++ .../RecursiveSchemaAsync/properties.ts | 153 ++++++++++ website/src/routes/api/menu.md | 5 + .../guides/(main-concepts)/schemas/index.mdx | 5 + .../routes/guides/(schemas)/other/index.mdx | 61 ++-- 31 files changed, 2270 insertions(+), 36 deletions(-) create mode 100644 library/src/schemas/recursive/index.ts create mode 100644 library/src/schemas/recursive/recursive.test-d.ts create mode 100644 library/src/schemas/recursive/recursive.test.ts create mode 100644 library/src/schemas/recursive/recursive.ts create mode 100644 library/src/schemas/recursive/recursiveAsync.test-d.ts create mode 100644 library/src/schemas/recursive/recursiveAsync.test.ts create mode 100644 library/src/schemas/recursive/recursiveAsync.ts create mode 100644 website/src/routes/api/(async)/recursiveAsync/index.mdx create mode 100644 website/src/routes/api/(async)/recursiveAsync/properties.ts create mode 100644 website/src/routes/api/(schemas)/recursive/index.mdx create mode 100644 website/src/routes/api/(schemas)/recursive/properties.ts create mode 100644 website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx create mode 100644 website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts create mode 100644 website/src/routes/api/(types)/RecursiveSchema/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveSchema/properties.ts create mode 100644 website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts diff --git a/library/src/methods/flatten/flatten.test-d.ts b/library/src/methods/flatten/flatten.test-d.ts index b2b7b6d29..5306e4c2e 100644 --- a/library/src/methods/flatten/flatten.test-d.ts +++ b/library/src/methods/flatten/flatten.test-d.ts @@ -4,7 +4,11 @@ import type { NumberIssue, NumberSchema, ObjectSchema, + RecursiveSchema, + StringIssue, + StringSchema, } from '../../schemas/index.ts'; +import type { RecursiveSelfSchema } from '../../schemas/recursive/recursive.ts'; import type { ArrayPathItem, ObjectPathItem } from '../../types/index.ts'; import { flatten } from './flatten.ts'; @@ -46,6 +50,43 @@ describe('flatten', () => { lang: undefined, }, ]; + const recursiveIssues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: 123, + expected: 'string', + received: '123', + message: 'Invalid type: Expected string but received 123', + path: [ + { + type: 'object', + origin: 'value', + input: { subcategories: [{ name: 123 }] }, + key: 'subcategories', + value: [{ name: 123 }], + } satisfies ObjectPathItem, + { + type: 'array', + origin: 'value', + input: [{ name: 123 }], + key: 0, + value: { name: 123 }, + } satisfies ArrayPathItem, + { + type: 'object', + origin: 'value', + input: { name: 123 }, + key: 'name', + value: 123, + } satisfies ObjectPathItem, + ], + abortEarly: undefined, + abortPipeEarly: undefined, + issues: undefined, + lang: undefined, + }, + ]; test('should return generic flat errors', () => { expectTypeOf(flatten(issues)).toEqualTypeOf<{ @@ -81,6 +122,34 @@ describe('flatten', () => { }>(); }); + test('should return recursive flat errors', () => { + type Schema = RecursiveSchema< + ObjectSchema< + { + name: StringSchema; + subcategories: ArraySchema; + }, + undefined + > + >; + + expectTypeOf(flatten(recursiveIssues)).toEqualTypeOf<{ + readonly root?: [string, ...string[]]; + readonly nested?: Readonly< + Partial< + Record< + | 'name' + | 'subcategories' + | `subcategories.${number}` + | `subcategories.${number}.${string}`, + [string, ...string[]] + > + > + >; + readonly other?: [string, ...string[]]; + }>(); + }); + test('should accept readonly list of errors', () => { const readonlyIssues = issues as Readonly; expectTypeOf(flatten(readonlyIssues)).toEqualTypeOf<{ diff --git a/library/src/schemas/index.ts b/library/src/schemas/index.ts index 1d43af2a2..c4c285753 100644 --- a/library/src/schemas/index.ts +++ b/library/src/schemas/index.ts @@ -30,6 +30,7 @@ export * from './objectWithRest/index.ts'; export * from './optional/index.ts'; export * from './picklist/index.ts'; export * from './promise/index.ts'; +export * from './recursive/index.ts'; export * from './record/index.ts'; export * from './set/index.ts'; export * from './strictObject/index.ts'; diff --git a/library/src/schemas/recursive/index.ts b/library/src/schemas/recursive/index.ts new file mode 100644 index 000000000..c856b9551 --- /dev/null +++ b/library/src/schemas/recursive/index.ts @@ -0,0 +1,6 @@ +export { + recursive, + type GenericRecursiveSchema, + type RecursiveSchema, +} from './recursive.ts'; +export { recursiveAsync, type RecursiveSchemaAsync } from './recursiveAsync.ts'; diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts new file mode 100644 index 000000000..7e3349eee --- /dev/null +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -0,0 +1,270 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import { transform } from '../../actions/index.ts'; +import { pipe } from '../../methods/index.ts'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { array, type ArrayIssue, type ArraySchema } from '../array/index.ts'; +import { instance } from '../instance/index.ts'; +import { map } from '../map/index.ts'; +import { + object, + type ObjectIssue, + type ObjectSchema, +} from '../object/index.ts'; +import { optional } from '../optional/index.ts'; +import { set } from '../set/index.ts'; +import { + string, + type StringIssue, + type StringSchema, +} from '../string/index.ts'; +import { tuple } from '../tuple/index.ts'; +import { union, type UnionIssue, type UnionSchema } from '../union/index.ts'; +import { + type GenericRecursiveSchema, + recursive, + type RecursiveSchema, + type RecursiveSelfSchema, +} from './recursive.ts'; + +describe('recursive', () => { + test('should return schema object', () => { + const getter = (self: RecursiveSelfSchema) => + object({ + name: string(), + subcategories: array(self), + }); + expectTypeOf(recursive(getter)).toEqualTypeOf< + RecursiveSchema> + >(); + }); + + describe('should infer correct types', () => { + const Category = recursive((Category) => + object({ + name: string(), + subcategories: array(Category), + }) + ); + + interface Category { + name: string; + subcategories: Category[]; + } + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf< + ObjectIssue | ArrayIssue | StringIssue + >(); + }); + }); + + test('should infer optional recursive fields', () => { + const Category = recursive((self) => + object({ + name: string(), + parent: optional(self), + }) + ); + + interface Category { + name: string; + parent?: Category | undefined; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should preserve non-recursive object instances', () => { + class User { + private readonly _brand!: 'User'; + } + class CustomMap extends Map { + private readonly _brand!: 'CustomMap'; + } + class CustomSet extends Set { + private readonly _brand!: 'CustomSet'; + } + class CustomArray extends Array { + private readonly _brand!: 'CustomArray'; + } + + const Category = recursive((self) => + object({ + name: string(), + subcategories: array(self), + owner: instance(User), + url: instance(URL), + error: instance(Error), + bytes: instance(Uint8Array), + customMap: instance(CustomMap), + customSet: instance(CustomSet), + customArray: instance(CustomArray), + }) + ); + + interface Category { + name: string; + subcategories: Category[]; + owner: User; + url: URL; + error: Error; + bytes: InstanceType; + customMap: CustomMap; + customSet: CustomSet; + customArray: CustomArray; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should infer recursive collection fields', () => { + const Category = recursive((self) => + object({ + name: string(), + childrenByName: map(string(), self), + related: set(self), + pair: tuple([string(), self]), + }) + ); + + interface Category { + name: string; + childrenByName: Map; + related: Set; + pair: [string, Category]; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should infer nested recursive schema types', () => { + const Category = recursive((category) => + object({ + name: string(), + subcategories: array(category), + tags: array( + recursive((tag) => + object({ + name: string(), + aliases: array(tag), + }) + ) + ), + }) + ); + + interface Tag { + name: string; + aliases: Tag[]; + } + + interface Category { + name: string; + subcategories: Category[]; + tags: Tag[]; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should infer recursive union issues', () => { + type Schema = RecursiveSchema< + UnionSchema< + readonly [ + StringSchema, + ArraySchema, + ], + undefined + > + >; + + type Json = string | Json[]; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + StringIssue | ArrayIssue | UnionIssue + >(); + }); + + test('should infer recursive union values', () => { + const Json = recursive((self) => union([string(), array(self)])); + + type Json = string | Json[]; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + StringIssue | ArrayIssue | UnionIssue + >(); + }); + + test('should infer recursive output in pipes', () => { + const Category = pipe( + recursive((self) => + object({ + name: string(), + subcategories: array(self), + }) + ), + transform((category) => ({ ...category, label: category.name })) + ); + + interface Subcategory { + name: string; + subcategories: Subcategory[]; + } + + interface Category extends Subcategory { + label: string; + } + + expectTypeOf>().toEqualTypeOf(); + }); + + test('should support schema interface', () => { + type Schema = RecursiveSchema< + ObjectSchema< + { + name: StringSchema; + subcategories: ArraySchema; + }, + undefined + > + >; + + interface Input { + name: string; + subcategories: Input[]; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + ObjectIssue | StringIssue | ArrayIssue + >(); + }); + + test('should support generic schema interface', () => { + const Category = recursive((self) => + object({ + name: string(), + subcategories: array(self), + }) + ); + + expectTypeOf(Category).toExtend(); + }); +}); diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts new file mode 100644 index 000000000..2510fa794 --- /dev/null +++ b/library/src/schemas/recursive/recursive.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { FailureDataset, InferIssue } from '../../types/index.ts'; +import { expectNoSchemaIssue } from '../../vitest/index.ts'; +import { array } from '../array/index.ts'; +import { object } from '../object/index.ts'; +import { string } from '../string/index.ts'; +import { + recursive, + type RecursiveSchema, + type RecursiveSelfSchema, +} from './recursive.ts'; + +describe('recursive', () => { + test('should return schema object', () => { + const getter = (self: RecursiveSelfSchema) => + object({ + name: string(), + subcategories: array(self), + }); + expect(recursive(getter)).toStrictEqual({ + kind: 'schema', + type: 'recursive', + reference: recursive, + expects: 'unknown', + getter, + async: false, + '~standard': { + version: 1, + vendor: 'valibot', + validate: expect.any(Function), + }, + '~run': expect.any(Function), + } satisfies RecursiveSchema>); + }); + + describe('should return dataset without issues', () => { + const schema = recursive((self) => + object({ + name: string(), + subcategories: array(self), + }) + ); + + test('for recursive objects', () => { + expectNoSchemaIssue(schema, [ + { name: 'Root', subcategories: [] }, + { + name: 'Root', + subcategories: [ + { + name: 'Child', + subcategories: [{ name: 'Grandchild', subcategories: [] }], + }, + ], + }, + ]); + }); + + test('for nested recursive schemas', () => { + const schema = recursive((category) => + object({ + name: string(), + subcategories: array(category), + links: array( + recursive((link) => + object({ + name: string(), + target: category, + alternatives: array(link), + }) + ) + ), + }) + ); + const input = { + name: 'Root', + subcategories: [{ name: 'Child', subcategories: [], links: [] }], + links: [ + { + name: 'Primary', + target: { name: 'Target', subcategories: [], links: [] }, + alternatives: [ + { + name: 'Fallback', + target: { + name: 'Fallback target', + subcategories: [], + links: [], + }, + alternatives: [], + }, + ], + }, + ], + }; + + expect(schema['~run']({ value: input }, {})).toStrictEqual({ + typed: true, + value: input, + }); + }); + }); + + test('should return dataset with nested issues', () => { + const schema = recursive((self) => + object({ + name: string('message'), + subcategories: array(self), + }) + ); + const input = { + name: 'Root', + subcategories: [{ name: 123, subcategories: [] }], + }; + expect(schema['~run']({ value: input }, {})).toStrictEqual({ + typed: false, + value: input, + issues: [ + { + kind: 'schema', + type: 'string', + input: 123, + expected: 'string', + received: '123', + message: 'message', + requirement: undefined, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subcategories', + value: input.subcategories, + }, + { + type: 'array', + origin: 'value', + input: input.subcategories, + key: 0, + value: input.subcategories[0], + }, + { + type: 'object', + origin: 'value', + input: input.subcategories[0], + key: 'name', + value: 123, + }, + ], + issues: undefined, + lang: undefined, + abortEarly: undefined, + abortPipeEarly: undefined, + }, + ], + } satisfies FailureDataset>); + }); + + test('should call getter with self', () => { + const getter = vi.fn((self: RecursiveSelfSchema) => { + expect(self.type).toBe('recursive'); + return string(); + }); + const schema = recursive(getter); + schema['~run']({ value: 'foo' }, {}); + expect(getter).toHaveBeenCalledWith(schema); + }); +}); diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts new file mode 100644 index 000000000..12aaaffb2 --- /dev/null +++ b/library/src/schemas/recursive/recursive.ts @@ -0,0 +1,226 @@ +import type { + BaseIssue, + BaseSchema, + InferInput, + InferIssue, + InferOutput, + OutputDataset, +} from '../../types/index.ts'; +import { _standardSchema } from '../../utils/index.ts'; + +/** + * Recursive marker symbol. + * + * @internal + */ +export declare const RecursiveMarkerSymbol: unique symbol; + +/** + * Recursive marker type. + * + * @internal + */ +export type RecursiveMarker = typeof RecursiveMarkerSymbol; + +/** + * Recursive self schema interface. + * + * @internal + */ +export interface RecursiveSelfSchema + extends BaseSchema { + /** + * The schema type. + */ + readonly type: 'recursive'; +} + +// Only resolve object shapes that contain the hidden recursive marker so +// inferred class and object instances stay structurally intact. +type ContainsRecursiveMarker< + TValue, + TSeen = never, +> = TValue extends RecursiveMarker + ? true + : [TValue] extends [TSeen] + ? false + : TValue extends + | Map + | ReadonlyMap + ? ContainsRecursiveMarker + : TValue extends Set | ReadonlySet + ? ContainsRecursiveMarker + : TValue extends Promise + ? ContainsRecursiveMarker + : TValue extends readonly (infer TItem)[] + ? ContainsRecursiveMarker + : TValue extends object + ? { + [TKey in keyof TValue]: ContainsRecursiveMarker< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] + : false; + +// Named interfaces keep recursive array output from collapsing into circular +// type aliases while preserving normal array behavior. +/* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ + +interface RecursiveArray + extends Array> {} + +interface RecursiveReadonlyArray + extends ReadonlyArray> {} + +/* eslint-enable @typescript-eslint/no-empty-object-type */ + +type ResolveRecursiveArray< + TValue extends readonly unknown[], + TRoot, +> = number extends TValue['length'] + ? TValue extends (infer TItem)[] + ? RecursiveMarker extends TItem + ? RecursiveArray + : ResolveRecursiveValue[] + : TValue extends readonly (infer TItem)[] + ? RecursiveMarker extends TItem + ? RecursiveReadonlyArray + : readonly ResolveRecursiveValue[] + : never + : { + [TKey in keyof TValue]: ResolveRecursiveValue; + }; + +type ResolveRecursiveContainer = + TValue extends Map + ? Map< + ResolveRecursiveValue, + ResolveRecursiveValue + > + : TValue extends ReadonlyMap + ? ReadonlyMap< + ResolveRecursiveValue, + ResolveRecursiveValue + > + : TValue extends Set + ? Set> + : TValue extends ReadonlySet + ? ReadonlySet> + : TValue extends Promise + ? Promise> + : TValue extends readonly unknown[] + ? ResolveRecursiveArray + : TValue extends object + ? { + [TKey in keyof TValue]: ResolveRecursiveValue< + TValue[TKey], + TRoot + >; + } + : TValue; + +type ResolveRecursiveValue = TValue extends RecursiveMarker + ? ResolveRecursiveValue + : true extends ContainsRecursiveMarker + ? ResolveRecursiveContainer + : TValue; + +/** + * Resolve recursive input type. + * + * @internal + */ +export type ResolveRecursiveInput< + TValue, + TRoot = TValue, +> = ResolveRecursiveValue; + +/** + * Resolve recursive output type. + * + * @internal + */ +export type ResolveRecursiveOutput< + TValue, + TRoot = TValue, +> = ResolveRecursiveValue; + +/** + * Generic recursive schema interface. + */ +export interface GenericRecursiveSchema + extends BaseSchema> { + /** + * The schema type. + */ + readonly type: 'recursive'; + /** + * The expected property. + */ + readonly expects: 'unknown'; + /** + * The schema getter. + */ + readonly getter: ( + self: RecursiveSelfSchema + ) => BaseSchema>; +} + +/** + * Recursive schema interface. + */ +export interface RecursiveSchema< + TWrapped extends BaseSchema>, +> extends BaseSchema< + ResolveRecursiveInput>, + ResolveRecursiveOutput>, + InferIssue + > { + /** + * The schema type. + */ + readonly type: 'recursive'; + /** + * The schema reference. + */ + readonly reference: typeof recursive; + /** + * The expected property. + */ + readonly expects: 'unknown'; + /** + * The schema getter. + */ + readonly getter: (self: RecursiveSelfSchema) => TWrapped; +} + +/** + * Creates a recursive schema. + * + * @param getter The schema getter. + * + * @returns A recursive schema. + */ +// @__NO_SIDE_EFFECTS__ +export function recursive< + const TWrapped extends BaseSchema>, +>(getter: (self: RecursiveSelfSchema) => TWrapped): RecursiveSchema { + return _standardSchema({ + kind: 'schema', + type: 'recursive', + reference: recursive, + expects: 'unknown', + async: false, + getter, + '~run'(dataset, config) { + return this.getter(this as unknown as RecursiveSelfSchema)['~run']( + dataset, + config + ) as OutputDataset< + ResolveRecursiveOutput>, + InferIssue + >; + }, + }); +} diff --git a/library/src/schemas/recursive/recursiveAsync.test-d.ts b/library/src/schemas/recursive/recursiveAsync.test-d.ts new file mode 100644 index 000000000..99dca9ac2 --- /dev/null +++ b/library/src/schemas/recursive/recursiveAsync.test-d.ts @@ -0,0 +1,173 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { + arrayAsync, + type ArrayIssue, + type ArraySchemaAsync, +} from '../array/index.ts'; +import { instance } from '../instance/index.ts'; +import { mapAsync } from '../map/index.ts'; +import { + objectAsync, + type ObjectIssue, + type ObjectSchemaAsync, +} from '../object/index.ts'; +import { setAsync } from '../set/index.ts'; +import { + string, + type StringIssue, + type StringSchema, +} from '../string/index.ts'; +import type { UnionIssue, UnionSchemaAsync } from '../union/index.ts'; +import { + recursiveAsync, + type RecursiveSchemaAsync, + type RecursiveSelfSchemaAsync, +} from './recursiveAsync.ts'; + +describe('recursiveAsync', () => { + test('should return schema object', () => { + const getter = (self: RecursiveSelfSchemaAsync) => + objectAsync({ + name: string(), + subcategories: arrayAsync(self), + }); + expectTypeOf(recursiveAsync(getter)).toEqualTypeOf< + RecursiveSchemaAsync> + >(); + }); + + describe('should infer correct types', () => { + const Category = recursiveAsync((Category) => + objectAsync({ + name: string(), + subcategories: arrayAsync(Category), + }) + ); + + interface Category { + name: string; + subcategories: Category[]; + } + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf< + ObjectIssue | ArrayIssue | StringIssue + >(); + }); + }); + + test('should preserve non-recursive object instances', () => { + class User { + private readonly _brand!: 'User'; + } + class CustomMap extends Map { + private readonly _brand!: 'CustomMap'; + } + class CustomSet extends Set { + private readonly _brand!: 'CustomSet'; + } + class CustomArray extends Array { + private readonly _brand!: 'CustomArray'; + } + + const Category = recursiveAsync((self) => + objectAsync({ + name: string(), + subcategories: arrayAsync(self), + owner: instance(User), + url: instance(URL), + error: instance(Error), + bytes: instance(Uint8Array), + customMap: instance(CustomMap), + customSet: instance(CustomSet), + customArray: instance(CustomArray), + }) + ); + + interface Category { + name: string; + subcategories: Category[]; + owner: User; + url: URL; + error: Error; + bytes: InstanceType; + customMap: CustomMap; + customSet: CustomSet; + customArray: CustomArray; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should infer recursive collection fields', () => { + const Category = recursiveAsync((self) => + objectAsync({ + name: string(), + childrenByName: mapAsync(string(), self), + related: setAsync(self), + }) + ); + + interface Category { + name: string; + childrenByName: Map; + related: Set; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should infer recursive union issues', () => { + type Schema = RecursiveSchemaAsync< + UnionSchemaAsync< + readonly [ + StringSchema, + ArraySchemaAsync, + ], + undefined + > + >; + + type Json = string | Json[]; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + StringIssue | ArrayIssue | UnionIssue + >(); + }); + + test('should support schema interface', () => { + type Schema = RecursiveSchemaAsync< + ObjectSchemaAsync< + { + name: StringSchema; + subcategories: ArraySchemaAsync; + }, + undefined + > + >; + + interface Input { + name: string; + subcategories: Input[]; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + ObjectIssue | StringIssue | ArrayIssue + >(); + }); +}); diff --git a/library/src/schemas/recursive/recursiveAsync.test.ts b/library/src/schemas/recursive/recursiveAsync.test.ts new file mode 100644 index 000000000..5e8c716c7 --- /dev/null +++ b/library/src/schemas/recursive/recursiveAsync.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { FailureDataset, InferIssue } from '../../types/index.ts'; +import { expectNoSchemaIssueAsync } from '../../vitest/index.ts'; +import { arrayAsync } from '../array/index.ts'; +import { objectAsync } from '../object/index.ts'; +import { string } from '../string/index.ts'; +import { + recursiveAsync, + type RecursiveSchemaAsync, + type RecursiveSelfSchemaAsync, +} from './recursiveAsync.ts'; + +describe('recursiveAsync', () => { + test('should return schema object', () => { + const getter = (self: RecursiveSelfSchemaAsync) => + objectAsync({ + name: string(), + subcategories: arrayAsync(self), + }); + expect(recursiveAsync(getter)).toStrictEqual({ + kind: 'schema', + type: 'recursive', + reference: recursiveAsync, + expects: 'unknown', + getter, + async: true, + '~standard': { + version: 1, + vendor: 'valibot', + validate: expect.any(Function), + }, + '~run': expect.any(Function), + } satisfies RecursiveSchemaAsync>); + }); + + describe('should return dataset without issues', () => { + const schema = recursiveAsync((self) => + objectAsync({ + name: string(), + subcategories: arrayAsync(self), + }) + ); + + test('for recursive objects', async () => { + await expectNoSchemaIssueAsync(schema, [ + { name: 'Root', subcategories: [] }, + { + name: 'Root', + subcategories: [ + { + name: 'Child', + subcategories: [{ name: 'Grandchild', subcategories: [] }], + }, + ], + }, + ]); + }); + }); + + test('should return dataset with nested issues', async () => { + const schema = recursiveAsync((self) => + objectAsync({ + name: string('message'), + subcategories: arrayAsync(self), + }) + ); + const input = { + name: 'Root', + subcategories: [{ name: 123, subcategories: [] }], + }; + await expect(schema['~run']({ value: input }, {})).resolves.toStrictEqual({ + typed: false, + value: input, + issues: [ + { + kind: 'schema', + type: 'string', + input: 123, + expected: 'string', + received: '123', + message: 'message', + requirement: undefined, + path: [ + { + type: 'object', + origin: 'value', + input, + key: 'subcategories', + value: input.subcategories, + }, + { + type: 'array', + origin: 'value', + input: input.subcategories, + key: 0, + value: input.subcategories[0], + }, + { + type: 'object', + origin: 'value', + input: input.subcategories[0], + key: 'name', + value: 123, + }, + ], + issues: undefined, + lang: undefined, + abortEarly: undefined, + abortPipeEarly: undefined, + }, + ], + } satisfies FailureDataset>); + }); + + test('should call getter with self', async () => { + const getter = vi.fn((self: RecursiveSelfSchemaAsync) => { + expect(self.type).toBe('recursive'); + return string(); + }); + const schema = recursiveAsync(getter); + await schema['~run']({ value: 'foo' }, {}); + expect(getter).toHaveBeenCalledWith(schema); + }); + + test('should support async getters', async () => { + const schema = recursiveAsync(async () => string()); + await expectNoSchemaIssueAsync(schema, ['foo']); + }); +}); diff --git a/library/src/schemas/recursive/recursiveAsync.ts b/library/src/schemas/recursive/recursiveAsync.ts new file mode 100644 index 000000000..45bba934b --- /dev/null +++ b/library/src/schemas/recursive/recursiveAsync.ts @@ -0,0 +1,94 @@ +import type { + BaseIssue, + BaseSchema, + BaseSchemaAsync, + InferInput, + InferIssue, + InferOutput, + MaybePromise, + OutputDataset, +} from '../../types/index.ts'; +import { _standardSchema } from '../../utils/index.ts'; +import type { + recursive, + RecursiveMarker, + ResolveRecursiveInput, + ResolveRecursiveOutput, +} from './recursive.ts'; + +/** + * Recursive self schema async interface. + * + * @internal + */ +export interface RecursiveSelfSchemaAsync + extends BaseSchemaAsync { + /** + * The schema type. + */ + readonly type: 'recursive'; +} + +/** + * Recursive schema async interface. + */ +export interface RecursiveSchemaAsync< + TWrapped extends + | BaseSchema> + | BaseSchemaAsync>, +> extends BaseSchemaAsync< + ResolveRecursiveInput>, + ResolveRecursiveOutput>, + InferIssue + > { + /** + * The schema type. + */ + readonly type: 'recursive'; + /** + * The schema reference. + */ + readonly reference: typeof recursive | typeof recursiveAsync; + /** + * The expected property. + */ + readonly expects: 'unknown'; + /** + * The schema getter. + */ + readonly getter: (self: RecursiveSelfSchemaAsync) => MaybePromise; +} + +/** + * Creates a recursive schema. + * + * @param getter The schema getter. + * + * @returns A recursive schema. + */ +// @__NO_SIDE_EFFECTS__ +export function recursiveAsync< + const TWrapped extends + | BaseSchema> + | BaseSchemaAsync>, +>( + getter: (self: RecursiveSelfSchemaAsync) => MaybePromise +): RecursiveSchemaAsync { + return _standardSchema({ + kind: 'schema', + type: 'recursive', + reference: recursiveAsync, + expects: 'unknown', + async: true, + getter, + async '~run'(dataset, config) { + const outputDataset = await ( + await this.getter(this as unknown as RecursiveSelfSchemaAsync) + )['~run'](dataset, config); + return outputDataset as OutputDataset< + ResolveRecursiveOutput>, + InferIssue + >; + }, + }); +} diff --git a/library/src/types/issue.ts b/library/src/types/issue.ts index 348c35fef..e307e5814 100644 --- a/library/src/types/issue.ts +++ b/library/src/types/issue.ts @@ -43,6 +43,8 @@ import type { RecordIssue, RecordSchema, RecordSchemaAsync, + RecursiveSchema, + RecursiveSchemaAsync, SetIssue, SetSchema, SetSchemaAsync, @@ -67,6 +69,8 @@ import type { VariantSchema, VariantSchemaAsync, } from '../schemas/index.ts'; +import type { RecursiveSelfSchema } from '../schemas/recursive/recursive.ts'; +import type { RecursiveSelfSchemaAsync } from '../schemas/recursive/recursiveAsync.ts'; import type { Config } from './config.ts'; import type { InferInput } from './infer.ts'; import type { ObjectEntries, ObjectEntriesAsync } from './object.ts'; @@ -511,6 +515,7 @@ export type IssueDotPath< > > | LazySchema + | RecursiveSchema | NonNullableSchema< infer TWrapped, | ErrorMessage @@ -590,6 +595,9 @@ export type IssueDotPath< > > | LazySchemaAsync + | RecursiveSchemaAsync< + infer TWrapped + > | NonNullableSchemaAsync< infer TWrapped, | ErrorMessage @@ -670,5 +678,10 @@ export type IssueDotPath< > > ? IssueDotPath - : // Otherwise - never; + : // Recursive self + TSchema extends + | RecursiveSelfSchema + | RecursiveSelfSchemaAsync + ? string + : // Otherwise + never; diff --git a/library/src/utils/getDotPath/getDotPath.test-d.ts b/library/src/utils/getDotPath/getDotPath.test-d.ts index a07753b38..9373251f4 100644 --- a/library/src/utils/getDotPath/getDotPath.test-d.ts +++ b/library/src/utils/getDotPath/getDotPath.test-d.ts @@ -1,10 +1,16 @@ import { describe, expectTypeOf, test } from 'vitest'; import type { ArraySchema, + ArraySchemaAsync, NumberIssue, NumberSchema, ObjectSchema, + ObjectSchemaAsync, + RecursiveSchemaAsync, + StringIssue, + StringSchema, } from '../../schemas/index.ts'; +import type { RecursiveSelfSchemaAsync } from '../../schemas/recursive/recursiveAsync.ts'; import type { ArrayPathItem, ObjectPathItem } from '../../types/index.ts'; import { getDotPath } from './getDotPath.ts'; @@ -44,6 +50,41 @@ describe('getDotPath', () => { issues: undefined, lang: undefined, }; + const recursiveIssue: StringIssue = { + kind: 'schema', + type: 'string', + input: 123, + expected: 'string', + received: '123', + message: 'Invalid type: Expected string but received 123', + path: [ + { + type: 'object', + origin: 'value', + input: { subcategories: [{ name: 123 }] }, + key: 'subcategories', + value: [{ name: 123 }], + } satisfies ObjectPathItem, + { + type: 'array', + origin: 'value', + input: [{ name: 123 }], + key: 0, + value: { name: 123 }, + } satisfies ArrayPathItem, + { + type: 'object', + origin: 'value', + input: { name: 123 }, + key: 'name', + value: 123, + } satisfies ObjectPathItem, + ], + abortEarly: undefined, + abortPipeEarly: undefined, + issues: undefined, + lang: undefined, + }; test('should return generic dot path', () => { expectTypeOf(getDotPath(issue)).toEqualTypeOf(); @@ -63,4 +104,24 @@ describe('getDotPath', () => { 'dot' | `dot.${number}` | `dot.${number}.path` | null >(); }); + + test('should return recursive dot path', () => { + type Schema = RecursiveSchemaAsync< + ObjectSchemaAsync< + { + name: StringSchema; + subcategories: ArraySchemaAsync; + }, + undefined + > + >; + + expectTypeOf(getDotPath(recursiveIssue)).toEqualTypeOf< + | 'name' + | 'subcategories' + | `subcategories.${number}` + | `subcategories.${number}.${string}` + | null + >(); + }); }); diff --git a/packages/to-json-schema/README.md b/packages/to-json-schema/README.md index e83ba400d..e2f7081f8 100644 --- a/packages/to-json-schema/README.md +++ b/packages/to-json-schema/README.md @@ -44,6 +44,7 @@ This package is particularly popular for: | `optional` | ✅ | | | `picklist` | ⚠️ | Only JSON compatible values are supported | | `record` | ⚠️ | Only `string` schemas for the key of the record are supported. Adds `propertyNames` for key validation (not available in OpenAPI 3.0) | +| `recursive` | ✅ | Missing definitions will be generated automatically | | `strictObject` | ✅ | | | `strictTuple` | ✅ | | | `string` | ✅ | | diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts index f2622251e..63395d9d7 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts @@ -1724,6 +1724,140 @@ describe('convertSchema', () => { ) ).toStrictEqual({ $ref: '#/$custom/reference' }); }); + + test('should convert recursive schema', () => { + const schema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + const context = createContext(); + const jsonSchema = convertSchema({}, schema, undefined, context); + const referenceId = context.referenceMap.get(schema); + + expect(jsonSchema).toStrictEqual({ + $ref: `#/$defs/${referenceId}`, + }); + expect(context.definitions).toStrictEqual({ + [referenceId!]: { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: `#/$defs/${referenceId}` }, + }, + }, + required: ['name', 'subcategories'], + }, + }); + }); + + test('should convert recursive schema as definition', () => { + const schema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + const context = createContext({ + definitions: {}, + referenceMap: new ReferenceMap().set(schema, 'Category'), + }); + + expect(convertSchema({}, schema, undefined, context, true)).toStrictEqual( + { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: '#/$defs/Category' }, + }, + }, + required: ['name', 'subcategories'], + } + ); + expect(context.definitions).toStrictEqual({ + Category: { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: '#/$defs/Category' }, + }, + }, + required: ['name', 'subcategories'], + }, + }); + }); + + test('should convert recursive schema with inherited definition key', () => { + const schema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + const context = createContext({ + definitions: {}, + referenceMap: new ReferenceMap().set(schema, 'toString'), + }); + + expect(convertSchema({}, schema, undefined, context, true)).toStrictEqual( + { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: '#/$defs/toString' }, + }, + }, + required: ['name', 'subcategories'], + } + ); + expect( + Object.prototype.hasOwnProperty.call(context.definitions, 'toString') + ).toBe(true); + expect(context.definitions.toString).toStrictEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: '#/$defs/toString' }, + }, + }, + required: ['name', 'subcategories'], + }); + }); + + test('should convert recursive schema without custom reference ID', () => { + const schema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + const schemaReference: unknown = schema; + expect( + convertSchema( + {}, + schema, + { + overrideRef({ valibotSchema }) { + if (valibotSchema === schemaReference) { + return '#/$custom/reference'; + } + }, + }, + createContext() + ) + ).toStrictEqual({ $ref: '#/$custom/reference' }); + }); }); describe('other schemas', () => { diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index 224399e90..6b7cb8605 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -97,6 +97,7 @@ type Schema = v.ErrorMessage | undefined > | v.NeverSchema | undefined> + | v.GenericRecursiveSchema | v.LazySchema>>; /** @@ -595,6 +596,54 @@ export function convertSchema( break; } + case 'recursive': { + // Get reference ID of recursive Valibot schema + let referenceId = context.referenceMap.get(valibotSchema); + + // Add recursive Valibot schema to reference map, if necessary + if (!referenceId) { + referenceId = `${refCount++}`; + context.referenceMap.set(valibotSchema, referenceId); + } + + // Add recursive Valibot schema to definitions, if necessary + if ( + !Object.prototype.hasOwnProperty.call(context.definitions, referenceId) + ) { + context.definitions[referenceId] = convertSchema( + {}, + valibotSchema.getter(valibotSchema as never) as SchemaOrPipe, + config, + context, + true + ); + } + + // Return definition directly, if reference is skipped + if (skipRef) { + Object.assign(jsonSchema, context.definitions[referenceId]); + + // Otherwise, add reference to JSON Schema object + } else { + jsonSchema.$ref = getDefinitionRef(referenceId); + + // Override reference, if necessary + if (config?.overrideRef) { + const refOverride = config.overrideRef({ + ...context, + referenceId, + valibotSchema, + jsonSchema, + }); + if (refOverride) { + jsonSchema.$ref = refOverride; + } + } + } + + break; + } + case 'lazy': { // Get wrapped Valibot schema let wrappedValibotSchema = context.getterMap.get(valibotSchema.getter); diff --git a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts index e73a71a5a..f35cd65be 100644 --- a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts +++ b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts @@ -362,6 +362,68 @@ describe('toJsonSchema', () => { }); }); + test('for generated recursive schema definition', () => { + const category = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + const jsonSchema = toJsonSchema(category); + const referenceIds = Object.keys(jsonSchema.$defs ?? {}); + expect(referenceIds).toHaveLength(1); + const referenceId = referenceIds[0]!; + + expect(jsonSchema).toStrictEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: `#/$defs/${referenceId}`, + $defs: { + [referenceId]: { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: `#/$defs/${referenceId}` }, + }, + }, + required: ['name', 'subcategories'], + }, + }, + }); + }); + + test('for recursive definitions with JSON Pointer special characters', () => { + const category = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + + expect( + toJsonSchema(category, { + definitions: { 'Category/Node~': category }, + }) + ).toStrictEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/$defs/Category~1Node~0', + $defs: { + 'Category/Node~': { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: '#/$defs/Category~1Node~0' }, + }, + }, + required: ['name', 'subcategories'], + }, + }, + }); + }); + test('for definitions with JSON Pointer special characters', () => { const sharedSchema = v.object({ name: v.string() }); expect( diff --git a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts index 7864617e8..083b9ac39 100644 --- a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts +++ b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts @@ -163,6 +163,29 @@ describe('toJsonSchemaDefs', () => { }, }); }); + + test('for recursive schema definition', () => { + const category = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + + expect(toJsonSchemaDefs({ category })).toStrictEqual({ + category: { + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: '#/$defs/category' }, + }, + }, + required: ['name', 'subcategories'], + }, + }); + }); }); describe('should throw error', () => { diff --git a/website/src/routes/api/(async)/lazyAsync/index.mdx b/website/src/routes/api/(async)/lazyAsync/index.mdx index f4af6773b..c8735b10c 100644 --- a/website/src/routes/api/(async)/lazyAsync/index.mdx +++ b/website/src/routes/api/(async)/lazyAsync/index.mdx @@ -5,6 +5,7 @@ source: /schemas/lazy/lazyAsync.ts contributors: - fabian-hiller - EltonLobo07 + - yslpn --- import { ApiList, Link, Property } from '~/components'; @@ -28,7 +29,9 @@ const Schema = v.lazyAsync(getter); ### Explanation -The `getter` function is called lazily to retrieve the schema. This is necessary to be able to access the input through the first argument of the `getter` function and to avoid a circular dependency for recursive schemas. +The `getter` function is called lazily to retrieve the schema. This is useful when you need to choose a schema based on the input value. + +> For recursive schemas with inferred input and output types, use `recursiveAsync` instead. ## Returns @@ -42,7 +45,7 @@ The following examples show how `lazyAsync` can be used. Recursive schema to validate transactions. -> Due to a TypeScript limitation, the input and output types of recursive schemas cannot be inferred automatically. Therefore, you must explicitly specify these types using `GenericSchemaAsync`. +> In most cases, `recursiveAsync` is the better choice for recursive schemas because it can infer input and output types automatically. ```ts import { isTransactionValid } from '~/api'; @@ -145,6 +148,7 @@ The following APIs can be combined with `lazyAsync`. 'picklist', 'promise', 'record', + 'recursive', 'set', 'strictObject', 'strictTuple', diff --git a/website/src/routes/api/(async)/recursiveAsync/index.mdx b/website/src/routes/api/(async)/recursiveAsync/index.mdx new file mode 100644 index 000000000..cc04f929f --- /dev/null +++ b/website/src/routes/api/(async)/recursiveAsync/index.mdx @@ -0,0 +1,89 @@ +--- +title: recursiveAsync +description: Creates a recursive schema. +source: /schemas/recursive/recursiveAsync.ts +contributors: + - yslpn +--- + +import { ApiList, Link, Property } from '~/components'; +import { properties } from './properties'; + +# recursiveAsync + +Creates a recursive schema. + +```ts +const Schema = v.recursiveAsync(getter); +``` + +## Generics + +- `TWrapped` + +## Parameters + +- `getter` + +### Explanation + +The `getter` function receives a `self` schema that can be used inside the schema returned by `getter`. This allows Valibot to infer recursive input and output types automatically. + +> When you nest `self`, use async-compatible schemas such as `objectAsync` and `arrayAsync`. + +## Returns + +- `Schema` + +## Examples + +The following examples show how `recursiveAsync` can be used. + +### Category schema + +Recursive schema to validate nested categories asynchronously. + +```ts +const CategorySchema = v.recursiveAsync((self) => + v.objectAsync({ + name: v.string(), + subcategories: v.arrayAsync(self), + }) +); +``` + +### Async getter schema + +Recursive schema with an async getter. + +```ts +const CategorySchema = v.recursiveAsync(async (self) => + v.objectAsync({ + name: v.string(), + subcategories: v.arrayAsync(self), + }) +); +``` + +## Related + +The following APIs can be combined with `recursiveAsync`. + +### Schemas + + + +### Methods + + diff --git a/website/src/routes/api/(async)/recursiveAsync/properties.ts b/website/src/routes/api/(async)/recursiveAsync/properties.ts new file mode 100644 index 000000000..cae799047 --- /dev/null +++ b/website/src/routes/api/(async)/recursiveAsync/properties.ts @@ -0,0 +1,80 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TWrapped: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + { + type: 'custom', + name: 'BaseSchemaAsync', + href: '../BaseSchemaAsync/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + ], + }, + }, + getter: { + type: { + type: 'function', + params: [ + { + name: 'self', + type: { + type: 'custom', + name: 'RecursiveSelfSchemaAsync', + }, + }, + ], + return: { + type: 'custom', + name: 'MaybePromise', + href: '../MaybePromise/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + }, + }, + Schema: { + type: { + type: 'custom', + name: 'RecursiveSchemaAsync', + href: '../RecursiveSchemaAsync/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + }, +}; diff --git a/website/src/routes/api/(schemas)/lazy/index.mdx b/website/src/routes/api/(schemas)/lazy/index.mdx index ebcc2d5e8..ac70ac109 100644 --- a/website/src/routes/api/(schemas)/lazy/index.mdx +++ b/website/src/routes/api/(schemas)/lazy/index.mdx @@ -6,6 +6,7 @@ contributors: - fabian-hiller - EltonLobo07 - morinokami + - yslpn --- import { ApiList, Link, Property } from '~/components'; @@ -29,9 +30,9 @@ const Schema = v.lazy(getter); ### Explanation -The `getter` function is called lazily to retrieve the schema. This is necessary to be able to access the input through the first argument of the `getter` function and to avoid a circular dependency for recursive schemas. +The `getter` function is called lazily to retrieve the schema. This is useful when you need to choose a schema based on the input value. -> Due to a TypeScript limitation, the input and output types of recursive schemas cannot be inferred automatically. Therefore, you must explicitly specify these types using `GenericSchema`. Please see the examples below. +> For recursive schemas with inferred input and output types, use `recursive` instead. ## Returns @@ -41,10 +42,12 @@ The `getter` function is called lazily to retrieve the schema. This is necessary The following examples show how `lazy` can be used. -### Binary tree schema +### Legacy binary tree schema Recursive schema to validate a binary tree. +> In most cases, `recursive` is the better choice for recursive schemas because it can infer input and output types automatically. + ```ts type BinaryTree = { element: string; @@ -63,6 +66,8 @@ const BinaryTreeSchema: v.GenericSchema = v.object({ Schema to validate all possible `JSON` values. +> In most cases, `recursive` is the better choice for recursive schemas because it can infer input and output types automatically. + ```ts import * as v from 'valibot'; @@ -157,6 +162,7 @@ The following APIs can be combined with `lazy`. 'picklist', 'promise', 'record', + 'recursive', 'set', 'strictObject', 'strictTuple', diff --git a/website/src/routes/api/(schemas)/recursive/index.mdx b/website/src/routes/api/(schemas)/recursive/index.mdx new file mode 100644 index 000000000..3bdea6225 --- /dev/null +++ b/website/src/routes/api/(schemas)/recursive/index.mdx @@ -0,0 +1,109 @@ +--- +title: recursive +description: Creates a recursive schema. +source: /schemas/recursive/recursive.ts +contributors: + - yslpn +--- + +import { ApiList, Link, Property } from '~/components'; +import { properties } from './properties'; + +# recursive + +Creates a recursive schema. + +```ts +const Schema = v.recursive(getter); +``` + +## Generics + +- `TWrapped` + +## Parameters + +- `getter` + +### Explanation + +The `getter` function receives a `self` schema that can be used inside the schema returned by `getter`. This allows Valibot to infer recursive input and output types automatically. + +> If you need to choose a schema based on the input value, use `lazy` instead. + +## Returns + +- `Schema` + +## Examples + +The following examples show how `recursive` can be used. + +### Category schema + +Recursive schema to validate nested categories. + +```ts +const CategorySchema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) +); +``` + +### Optional parent schema + +Recursive schema with an optional reference to the same schema. + +```ts +const CategorySchema = v.recursive((Category) => + v.object({ + name: v.string(), + parent: v.optional(Category), + }) +); +``` + +### Transform root output + +Schema that transforms the root output after recursive parsing. + +```ts +const CategoryWithLabelSchema = v.pipe( + v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ), + v.transform((category) => ({ ...category, label: category.name })) +); +``` + +## Related + +The following APIs can be combined with `recursive`. + +### Schemas + + + +### Methods + + + +### Actions + + diff --git a/website/src/routes/api/(schemas)/recursive/properties.ts b/website/src/routes/api/(schemas)/recursive/properties.ts new file mode 100644 index 000000000..4f1e2f7d9 --- /dev/null +++ b/website/src/routes/api/(schemas)/recursive/properties.ts @@ -0,0 +1,53 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TWrapped: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + }, + getter: { + type: { + type: 'function', + params: [ + { + name: 'self', + type: { + type: 'custom', + name: 'RecursiveSelfSchema', + }, + }, + ], + return: { + type: 'custom', + name: 'TWrapped', + }, + }, + }, + Schema: { + type: { + type: 'custom', + name: 'RecursiveSchema', + href: '../RecursiveSchema/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + }, +}; diff --git a/website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx b/website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx new file mode 100644 index 000000000..c8b1668c7 --- /dev/null +++ b/website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx @@ -0,0 +1,20 @@ +--- +title: GenericRecursiveSchema +description: Generic recursive schema interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# GenericRecursiveSchema + +Generic recursive schema interface. + +## Definition + +- `GenericRecursiveSchema` + - `type` + - `expects` + - `getter` diff --git a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts new file mode 100644 index 000000000..223e1173e --- /dev/null +++ b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts @@ -0,0 +1,63 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + BaseSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + }, + type: { + type: { + type: 'string', + value: 'recursive', + }, + }, + expects: { + type: { + type: 'string', + value: 'unknown', + }, + }, + getter: { + type: { + type: 'function', + params: [ + { + name: 'self', + type: { + type: 'custom', + name: 'RecursiveSelfSchema', + }, + }, + ], + return: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveSchema/index.mdx b/website/src/routes/api/(types)/RecursiveSchema/index.mdx new file mode 100644 index 000000000..b6f5678b2 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSchema/index.mdx @@ -0,0 +1,25 @@ +--- +title: RecursiveSchema +description: Recursive schema interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveSchema + +Recursive schema interface. + +## Generics + +- `TWrapped` + +## Definition + +- `RecursiveSchema` + - `type` + - `reference` + - `expects` + - `getter` diff --git a/website/src/routes/api/(types)/RecursiveSchema/properties.ts b/website/src/routes/api/(types)/RecursiveSchema/properties.ts new file mode 100644 index 000000000..e666ee9b4 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSchema/properties.ts @@ -0,0 +1,115 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TWrapped: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + }, + BaseSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + { + type: 'custom', + name: 'ResolveRecursiveInput', + generics: [ + { + type: 'custom', + name: 'InferInput', + href: '../InferInput/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + ], + }, + { + type: 'custom', + name: 'ResolveRecursiveOutput', + generics: [ + { + type: 'custom', + name: 'InferOutput', + href: '../InferOutput/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + ], + }, + { + type: 'custom', + name: 'InferIssue', + href: '../InferIssue/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + ], + }, + }, + type: { + type: { + type: 'string', + value: 'recursive', + }, + }, + reference: { + type: { + type: 'custom', + modifier: 'typeof', + name: 'recursive', + href: '../recursive/', + }, + }, + expects: { + type: { + type: 'string', + value: 'unknown', + }, + }, + getter: { + type: { + type: 'function', + params: [ + { + name: 'self', + type: { + type: 'custom', + name: 'RecursiveSelfSchema', + }, + }, + ], + return: { + type: 'custom', + name: 'TWrapped', + }, + }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx b/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx new file mode 100644 index 000000000..4d4a86326 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx @@ -0,0 +1,25 @@ +--- +title: RecursiveSchemaAsync +description: Recursive schema async interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveSchemaAsync + +Recursive schema async interface. + +## Generics + +- `TWrapped` + +## Definition + +- `RecursiveSchemaAsync` + - `type` + - `reference` + - `expects` + - `getter` diff --git a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts new file mode 100644 index 000000000..dc8a07b80 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts @@ -0,0 +1,153 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TWrapped: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + { + type: 'custom', + name: 'BaseSchemaAsync', + href: '../BaseSchemaAsync/', + generics: [ + 'unknown', + 'unknown', + { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + ], + }, + ], + }, + }, + BaseSchemaAsync: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchemaAsync', + href: '../BaseSchemaAsync/', + generics: [ + { + type: 'custom', + name: 'ResolveRecursiveInput', + generics: [ + { + type: 'custom', + name: 'InferInput', + href: '../InferInput/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + ], + }, + { + type: 'custom', + name: 'ResolveRecursiveOutput', + generics: [ + { + type: 'custom', + name: 'InferOutput', + href: '../InferOutput/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + ], + }, + { + type: 'custom', + name: 'InferIssue', + href: '../InferIssue/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + ], + }, + }, + type: { + type: { + type: 'string', + value: 'recursive', + }, + }, + reference: { + type: { + type: 'union', + options: [ + { + type: 'custom', + modifier: 'typeof', + name: 'recursive', + href: '../recursive/', + }, + { + type: 'custom', + modifier: 'typeof', + name: 'recursiveAsync', + href: '../recursiveAsync/', + }, + ], + }, + }, + expects: { + type: { + type: 'string', + value: 'unknown', + }, + }, + getter: { + type: { + type: 'function', + params: [ + { + name: 'self', + type: { + type: 'custom', + name: 'RecursiveSelfSchemaAsync', + }, + }, + ], + return: { + type: 'custom', + name: 'MaybePromise', + href: '../MaybePromise/', + generics: [ + { + type: 'custom', + name: 'TWrapped', + }, + ], + }, + }, + }, +}; diff --git a/website/src/routes/api/menu.md b/website/src/routes/api/menu.md index cc6c396d5..e63e6a9da 100644 --- a/website/src/routes/api/menu.md +++ b/website/src/routes/api/menu.md @@ -35,6 +35,7 @@ - [picklist](/api/picklist/) - [promise](/api/promise/) - [record](/api/record/) +- [recursive](/api/recursive/) - [set](/api/set/) - [strictObject](/api/strictObject/) - [strictTuple](/api/strictTuple/) @@ -271,6 +272,7 @@ - [rawCheckAsync](/api/rawCheckAsync/) - [rawTransformAsync](/api/rawTransformAsync/) - [recordAsync](/api/recordAsync/) +- [recursiveAsync](/api/recursiveAsync/) - [requiredAsync](/api/requiredAsync/) - [returnsAsync](/api/returnsAsync/) - [safeParseAsync](/api/safeParseAsync/) @@ -399,6 +401,7 @@ - [GenericPipeActionAsync](/api/GenericPipeActionAsync/) - [GenericPipeItem](/api/GenericPipeItem/) - [GenericPipeItemAsync](/api/GenericPipeItemAsync/) +- [GenericRecursiveSchema](/api/GenericRecursiveSchema/) - [GenericSchema](/api/GenericSchema/) - [GenericSchemaAsync](/api/GenericSchemaAsync/) - [GenericTransformation](/api/GenericTransformation/) @@ -667,6 +670,8 @@ - [RecordIssue](/api/RecordIssue/) - [RecordSchema](/api/RecordSchema/) - [RecordSchemaAsync](/api/RecordSchemaAsync/) +- [RecursiveSchema](/api/RecursiveSchema/) +- [RecursiveSchemaAsync](/api/RecursiveSchemaAsync/) - [ReduceItemsAction](/api/ReduceItemsAction/) - [Reference](/api/Reference/) - [RegexAction](/api/RegexAction/) diff --git a/website/src/routes/guides/(main-concepts)/schemas/index.mdx b/website/src/routes/guides/(main-concepts)/schemas/index.mdx index b97d32b6e..02d6470a4 100644 --- a/website/src/routes/guides/(main-concepts)/schemas/index.mdx +++ b/website/src/routes/guides/(main-concepts)/schemas/index.mdx @@ -10,6 +10,7 @@ contributors: - alonidiom - yicrotkd - gotnoklu + - yslpn --- import { ApiList, Link } from '~/components'; @@ -122,6 +123,7 @@ Beyond primitive and complex values, there are also schema functions for more sp 'nullish', 'optional', 'picklist', + 'recursive', 'undefinedable', 'union', 'unknown', @@ -150,6 +152,9 @@ const NullableSchema = v.nullable(v.string()); // string | null const NullishSchema = v.nullish(v.string()); // string | null | undefined const OptionalSchema = v.optional(v.string()); // string | undefined const PicklistSchema = v.picklist(['a', 'b']); // 'a' | 'b' +const RecursiveSchema = v.recursive((self) => + v.object({ children: v.array(self) }) +); // { children: ...[] } const UndefinedableSchema = v.undefinedable(v.string()); // string | undefined const UnionSchema = v.union([v.string(), v.number()]); // string | number const UnknownSchema = v.unknown(); // unknown diff --git a/website/src/routes/guides/(schemas)/other/index.mdx b/website/src/routes/guides/(schemas)/other/index.mdx index ec3fa6574..c9ba5e51f 100644 --- a/website/src/routes/guides/(schemas)/other/index.mdx +++ b/website/src/routes/guides/(schemas)/other/index.mdx @@ -2,16 +2,18 @@ title: Other description: >- This guide explains other special schema functions such as `literal`, - `instance`, `custom` and `lazy` that are not covered in the other guides. + `instance`, `custom`, `recursive` and `lazy` that are not covered in the + other guides. contributors: - fabian-hiller + - yslpn --- import { Link } from '~/components'; # Other -This guide explains other special schema functions such as `literal`, `instance`, `custom` and `lazy` that are not covered in the other guides. +This guide explains other special schema functions such as `literal`, `instance`, `custom`, `recursive` and `lazy` that are not covered in the other guides. ## Literal schema @@ -50,51 +52,52 @@ const PixelStringSchema = v.custom<`${number}px`>((input) => ); ``` -## Lazy schema - -The `lazy` schema function allows you to define recursive schemas. A recursive schema is a schema that references itself. For example, you can use it to define a schema for a tree-like data structure. +## Recursive schema -> Due to a TypeScript limitation, the input and output types cannot be inferred automatically in this case. Therefore, you must explicitly specify these types using the `GenericSchema` type. +The `recursive` schema function allows you to define schemas that reference themselves. The `self` argument can be used inside the returned schema, and Valibot can infer the recursive input and output types automatically. ```ts import * as v from 'valibot'; -type BinaryTree = { - element: string; - left: BinaryTree | null; - right: BinaryTree | null; -}; - -const BinaryTreeSchema: v.GenericSchema = v.object({ - element: v.string(), - left: v.nullable(v.lazy(() => BinaryTreeSchema)), - right: v.nullable(v.lazy(() => BinaryTreeSchema)), -}); +const CategorySchema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) +); ``` ### JSON schema -Another practical use case for `lazy` is a schema for all possible `JSON` values. These are all values that can be serialized and deserialized using `JSON.stringify()` and `JSON.parse()`. +Another practical use case for `recursive` is a schema for all possible `JSON` values. These are all values that can be serialized and deserialized using `JSON.stringify()` and `JSON.parse()`. ```ts import * as v from 'valibot'; -type JsonData = - | string - | number - | boolean - | null - | { [key: string]: JsonData } - | JsonData[]; - -const JsonSchema: v.GenericSchema = v.lazy(() => +const JsonSchema = v.recursive((self) => v.union([ v.string(), v.number(), v.boolean(), v.null(), - v.record(v.string(), JsonSchema), - v.array(JsonSchema), + v.record(v.string(), self), + v.array(self), ]) ); ``` + +## Lazy schema + +The `lazy` schema function calls a getter when parsing an input value. Use it when you need to choose a schema based on the input value. + +> For ordinary recursive schemas, `recursive` is usually a better choice because it can infer input and output types automatically. + +```ts +import * as v from 'valibot'; + +const EmailOrUrlSchema = v.lazy((input) => + typeof input === 'string' && input.startsWith('https://') + ? v.pipe(v.string(), v.url()) + : v.pipe(v.string(), v.email()) +); +``` From 58e4b743fd8f6b8494b6314fcf8363e7cf54173b Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 13 Jun 2026 11:28:48 +0200 Subject: [PATCH 02/39] fix: update reference from 'recursive' to 'recursiveAsync' in lazyAsync documentation --- website/src/routes/api/(async)/lazyAsync/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/routes/api/(async)/lazyAsync/index.mdx b/website/src/routes/api/(async)/lazyAsync/index.mdx index c8735b10c..b02118501 100644 --- a/website/src/routes/api/(async)/lazyAsync/index.mdx +++ b/website/src/routes/api/(async)/lazyAsync/index.mdx @@ -148,7 +148,7 @@ The following APIs can be combined with `lazyAsync`. 'picklist', 'promise', 'record', - 'recursive', + 'recursiveAsync', 'set', 'strictObject', 'strictTuple', From 0fcabea63962d11bc12397e76a592d4c0302850b Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 13 Jun 2026 11:33:14 +0200 Subject: [PATCH 03/39] test: verify getter call in recursive and recursiveAsync schemas --- library/src/schemas/recursive/recursive.test.ts | 2 ++ library/src/schemas/recursive/recursiveAsync.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index 2510fa794..0e65f42d8 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -162,7 +162,9 @@ describe('recursive', () => { return string(); }); const schema = recursive(getter); + expect(getter).not.toHaveBeenCalled(); schema['~run']({ value: 'foo' }, {}); + expect(getter).toHaveBeenCalledTimes(1); expect(getter).toHaveBeenCalledWith(schema); }); }); diff --git a/library/src/schemas/recursive/recursiveAsync.test.ts b/library/src/schemas/recursive/recursiveAsync.test.ts index 5e8c716c7..351c3a490 100644 --- a/library/src/schemas/recursive/recursiveAsync.test.ts +++ b/library/src/schemas/recursive/recursiveAsync.test.ts @@ -118,7 +118,9 @@ describe('recursiveAsync', () => { return string(); }); const schema = recursiveAsync(getter); + expect(getter).not.toHaveBeenCalled(); await schema['~run']({ value: 'foo' }, {}); + expect(getter).toHaveBeenCalledTimes(1); expect(getter).toHaveBeenCalledWith(schema); }); From 73df73975f250bbcca93a20bdde9e08906e600d7 Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 13 Jun 2026 11:33:27 +0200 Subject: [PATCH 04/39] feat: add test for converting recursive schema without colliding with existing definitions --- .../convertSchema/convertSchema.test.ts | 46 +++++++++++++++++++ .../converters/convertSchema/convertSchema.ts | 15 ++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts index 63395d9d7..28aea52fc 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts @@ -1754,6 +1754,52 @@ describe('convertSchema', () => { }); }); + test('should convert recursive schema without colliding with existing definitions', () => { + const schema = v.recursive((self) => + v.object({ + name: v.string(), + subcategories: v.array(self), + }) + ); + const definitions = Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [ + `${index}`, + { type: 'string' as const }, + ]) + ); + const expectedDefinitions = { ...definitions }; + const existingReferenceIds = new Set([ + '100', + ...Object.keys(definitions), + ]); + const context = createContext({ + definitions, + referenceMap: new ReferenceMap().set(v.string(), '100'), + }); + + const jsonSchema = convertSchema({}, schema, undefined, context); + const referenceId = context.referenceMap.get(schema); + + expect(referenceId).toBeDefined(); + expect(existingReferenceIds.has(referenceId!)).toBe(false); + expect(context.definitions).toMatchObject(expectedDefinitions); + expect(context.definitions['100']).toBeUndefined(); + expect(jsonSchema).toStrictEqual({ + $ref: `#/$defs/${referenceId}`, + }); + expect(context.definitions[referenceId!]).toStrictEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + subcategories: { + type: 'array', + items: { $ref: `#/$defs/${referenceId}` }, + }, + }, + required: ['name', 'subcategories'], + }); + }); + test('should convert recursive schema as definition', () => { const schema = v.recursive((self) => v.object({ diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index 6b7cb8605..831e1c03e 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -597,22 +597,27 @@ export function convertSchema( } case 'recursive': { + const recursiveSchema: v.GenericRecursiveSchema = valibotSchema; + // Get reference ID of recursive Valibot schema - let referenceId = context.referenceMap.get(valibotSchema); + let referenceId = context.referenceMap.get(recursiveSchema); // Add recursive Valibot schema to reference map, if necessary if (!referenceId) { - referenceId = `${refCount++}`; - context.referenceMap.set(valibotSchema, referenceId); + referenceId = context.referenceMap.createId(context.definitions); + context.referenceMap.set(recursiveSchema, referenceId); } // Add recursive Valibot schema to definitions, if necessary if ( !Object.prototype.hasOwnProperty.call(context.definitions, referenceId) ) { + const recursiveSelfSchema = recursiveSchema as Parameters< + typeof recursiveSchema.getter + >[0]; context.definitions[referenceId] = convertSchema( {}, - valibotSchema.getter(valibotSchema as never) as SchemaOrPipe, + recursiveSchema.getter(recursiveSelfSchema) as SchemaOrPipe, config, context, true @@ -632,7 +637,7 @@ export function convertSchema( const refOverride = config.overrideRef({ ...context, referenceId, - valibotSchema, + valibotSchema: recursiveSchema, jsonSchema, }); if (refOverride) { From 3d356e8f95bd7d0ce5255eca41e927c10ebfe2b4 Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 20 Jun 2026 15:10:22 +0200 Subject: [PATCH 05/39] docs: update API list in recursiveAsync documentation to include additional methods and actions --- .../api/(async)/recursiveAsync/index.mdx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/website/src/routes/api/(async)/recursiveAsync/index.mdx b/website/src/routes/api/(async)/recursiveAsync/index.mdx index cc04f929f..d3fe49847 100644 --- a/website/src/routes/api/(async)/recursiveAsync/index.mdx +++ b/website/src/routes/api/(async)/recursiveAsync/index.mdx @@ -71,6 +71,29 @@ The following APIs can be combined with `recursiveAsync`. ### Schemas + + +### Methods + + + +### Actions + + + +### Async + - -### Methods - - From 8911bf67e615dda858775e6f22a6c351f2a84269 Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 20 Jun 2026 15:16:13 +0200 Subject: [PATCH 06/39] feat: add support for recursive tuples with rest in recursive schema --- .../src/schemas/recursive/recursive.test-d.ts | 19 ++++++++++++++++ library/src/schemas/recursive/recursive.ts | 22 ++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index 7e3349eee..c57686c47 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -18,6 +18,7 @@ import { type StringSchema, } from '../string/index.ts'; import { tuple } from '../tuple/index.ts'; +import { tupleWithRest } from '../tupleWithRest/index.ts'; import { union, type UnionIssue, type UnionSchema } from '../union/index.ts'; import { type GenericRecursiveSchema, @@ -148,6 +149,24 @@ describe('recursive', () => { expectTypeOf>().toEqualTypeOf(); }); + test('should infer recursive tuple with rest', () => { + const Category = recursive((self) => + object({ + name: string(), + // Variadic tuple: a fixed leading element followed by a recursive rest + children: tupleWithRest([string()], self), + }) + ); + + interface Category { + name: string; + children: [string, ...Category[]]; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + test('should infer nested recursive schema types', () => { const Category = recursive((category) => object({ diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index 12aaaffb2..54224d9d7 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -79,15 +79,21 @@ type ResolveRecursiveArray< TValue extends readonly unknown[], TRoot, > = number extends TValue['length'] - ? TValue extends (infer TItem)[] - ? RecursiveMarker extends TItem - ? RecursiveArray - : ResolveRecursiveValue[] - : TValue extends readonly (infer TItem)[] + ? TValue extends + | readonly [unknown, ...unknown[]] + | readonly [...unknown[], unknown] + ? { + [TKey in keyof TValue]: ResolveRecursiveValue; + } + : TValue extends (infer TItem)[] ? RecursiveMarker extends TItem - ? RecursiveReadonlyArray - : readonly ResolveRecursiveValue[] - : never + ? RecursiveArray + : ResolveRecursiveValue[] + : TValue extends readonly (infer TItem)[] + ? RecursiveMarker extends TItem + ? RecursiveReadonlyArray + : readonly ResolveRecursiveValue[] + : never : { [TKey in keyof TValue]: ResolveRecursiveValue; }; From 18289af4acf8c32f6b9062199a6746eb731e51d5 Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Sat, 20 Jun 2026 15:35:55 +0200 Subject: [PATCH 07/39] refactor: add comments for recursive types --- library/src/schemas/recursive/recursive.ts | 27 +++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index 54224d9d7..beb43e067 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -75,16 +75,23 @@ interface RecursiveReadonlyArray /* eslint-enable @typescript-eslint/no-empty-object-type */ +// Maps each element of a tuple through the resolver, preserving the tuple's +// positional structure (fixed slots, labels, optionality, readonly). +type ResolveRecursiveTuple = { + [TKey in keyof TValue]: ResolveRecursiveValue; +}; + type ResolveRecursiveArray< TValue extends readonly unknown[], TRoot, > = number extends TValue['length'] - ? TValue extends + ? // Variadic tuples (e.g. `[string, ...self[]]`) also have a `number` length, + // but unlike plain arrays carry fixed leading or trailing elements, so map + // element-wise to keep those slots intact. + TValue extends | readonly [unknown, ...unknown[]] | readonly [...unknown[], unknown] - ? { - [TKey in keyof TValue]: ResolveRecursiveValue; - } + ? ResolveRecursiveTuple : TValue extends (infer TItem)[] ? RecursiveMarker extends TItem ? RecursiveArray @@ -94,10 +101,14 @@ type ResolveRecursiveArray< ? RecursiveReadonlyArray : readonly ResolveRecursiveValue[] : never - : { - [TKey in keyof TValue]: ResolveRecursiveValue; - }; - + : ResolveRecursiveTuple; + +// Rebuilds a container with its type arguments resolved. TypeScript cannot +// reconstruct an arbitrary generic with substituted type arguments, so each +// container kind must be enumerated explicitly. This list must cover every +// container a Valibot schema can infer; an unlisted generic (e.g. `WeakMap`) +// falls through to the object branch, which maps over its methods and leaves +// the marker in its type arguments unresolved. type ResolveRecursiveContainer = TValue extends Map ? Map< From fd3fd94b34aa0cacb7ff202d4e4d63cd23be819b Mon Sep 17 00:00:00 2001 From: Yaroslav Slepukhin Date: Mon, 29 Jun 2026 10:16:44 +0200 Subject: [PATCH 08/39] fix: add recursive schema types to public api and update documentation --- library/src/schemas/recursive/index.ts | 11 +++++- library/src/schemas/recursive/recursive.ts | 16 ++++----- .../src/schemas/recursive/recursiveAsync.ts | 2 -- .../api/(async)/recursiveAsync/properties.ts | 1 + .../api/(schemas)/recursive/properties.ts | 1 + .../GenericRecursiveSchema/properties.ts | 1 + .../api/(types)/RecursiveArray/index.mdx | 22 ++++++++++++ .../api/(types)/RecursiveArray/properties.ts | 35 +++++++++++++++++++ .../api/(types)/RecursiveMarker/index.mdx | 17 +++++++++ .../api/(types)/RecursiveMarker/properties.ts | 11 ++++++ .../(types)/RecursiveReadonlyArray/index.mdx | 22 ++++++++++++ .../RecursiveReadonlyArray/properties.ts | 35 +++++++++++++++++++ .../api/(types)/RecursiveSchema/properties.ts | 1 + .../RecursiveSchemaAsync/properties.ts | 1 + .../api/(types)/RecursiveSelfSchema/index.mdx | 18 ++++++++++ .../(types)/RecursiveSelfSchema/properties.ts | 31 ++++++++++++++++ .../RecursiveSelfSchemaAsync/index.mdx | 18 ++++++++++ .../RecursiveSelfSchemaAsync/properties.ts | 31 ++++++++++++++++ website/src/routes/api/menu.md | 5 +++ 19 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 website/src/routes/api/(types)/RecursiveArray/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveArray/properties.ts create mode 100644 website/src/routes/api/(types)/RecursiveMarker/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveMarker/properties.ts create mode 100644 website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts create mode 100644 website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveSelfSchema/properties.ts create mode 100644 website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx create mode 100644 website/src/routes/api/(types)/RecursiveSelfSchemaAsync/properties.ts diff --git a/library/src/schemas/recursive/index.ts b/library/src/schemas/recursive/index.ts index c856b9551..61cd59506 100644 --- a/library/src/schemas/recursive/index.ts +++ b/library/src/schemas/recursive/index.ts @@ -1,6 +1,15 @@ export { recursive, + type RecursiveMarkerSymbol, type GenericRecursiveSchema, + type RecursiveArray, + type RecursiveMarker, + type RecursiveReadonlyArray, type RecursiveSchema, + type RecursiveSelfSchema, } from './recursive.ts'; -export { recursiveAsync, type RecursiveSchemaAsync } from './recursiveAsync.ts'; +export { + recursiveAsync, + type RecursiveSchemaAsync, + type RecursiveSelfSchemaAsync, +} from './recursiveAsync.ts'; diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index beb43e067..b63c707c5 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -10,22 +10,16 @@ import { _standardSchema } from '../../utils/index.ts'; /** * Recursive marker symbol. - * - * @internal */ export declare const RecursiveMarkerSymbol: unique symbol; /** * Recursive marker type. - * - * @internal */ export type RecursiveMarker = typeof RecursiveMarkerSymbol; /** * Recursive self schema interface. - * - * @internal */ export interface RecursiveSelfSchema extends BaseSchema { @@ -67,10 +61,16 @@ type ContainsRecursiveMarker< // type aliases while preserving normal array behavior. /* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ -interface RecursiveArray +/** + * Recursive array interface. + */ +export interface RecursiveArray extends Array> {} -interface RecursiveReadonlyArray +/** + * Recursive readonly array interface. + */ +export interface RecursiveReadonlyArray extends ReadonlyArray> {} /* eslint-enable @typescript-eslint/no-empty-object-type */ diff --git a/library/src/schemas/recursive/recursiveAsync.ts b/library/src/schemas/recursive/recursiveAsync.ts index 45bba934b..214673211 100644 --- a/library/src/schemas/recursive/recursiveAsync.ts +++ b/library/src/schemas/recursive/recursiveAsync.ts @@ -18,8 +18,6 @@ import type { /** * Recursive self schema async interface. - * - * @internal */ export interface RecursiveSelfSchemaAsync extends BaseSchemaAsync { diff --git a/website/src/routes/api/(async)/recursiveAsync/properties.ts b/website/src/routes/api/(async)/recursiveAsync/properties.ts index cae799047..636ce4e7b 100644 --- a/website/src/routes/api/(async)/recursiveAsync/properties.ts +++ b/website/src/routes/api/(async)/recursiveAsync/properties.ts @@ -48,6 +48,7 @@ export const properties: Record = { type: { type: 'custom', name: 'RecursiveSelfSchemaAsync', + href: '../RecursiveSelfSchemaAsync/', }, }, ], diff --git a/website/src/routes/api/(schemas)/recursive/properties.ts b/website/src/routes/api/(schemas)/recursive/properties.ts index 4f1e2f7d9..6c75b610c 100644 --- a/website/src/routes/api/(schemas)/recursive/properties.ts +++ b/website/src/routes/api/(schemas)/recursive/properties.ts @@ -28,6 +28,7 @@ export const properties: Record = { type: { type: 'custom', name: 'RecursiveSelfSchema', + href: '../RecursiveSelfSchema/', }, }, ], diff --git a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts index 223e1173e..8aab3556e 100644 --- a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts +++ b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts @@ -40,6 +40,7 @@ export const properties: Record = { type: { type: 'custom', name: 'RecursiveSelfSchema', + href: '../RecursiveSelfSchema/', }, }, ], diff --git a/website/src/routes/api/(types)/RecursiveArray/index.mdx b/website/src/routes/api/(types)/RecursiveArray/index.mdx new file mode 100644 index 000000000..865700198 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveArray/index.mdx @@ -0,0 +1,22 @@ +--- +title: RecursiveArray +description: Recursive array interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveArray + +Recursive array interface. + +## Generics + +- `TItem` +- `TRoot` + +## Definition + +- `RecursiveArray` diff --git a/website/src/routes/api/(types)/RecursiveArray/properties.ts b/website/src/routes/api/(types)/RecursiveArray/properties.ts new file mode 100644 index 000000000..acce73463 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveArray/properties.ts @@ -0,0 +1,35 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TItem: { + modifier: 'extends', + type: 'any', + }, + TRoot: { + modifier: 'extends', + type: 'any', + }, + Array: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Array', + generics: [ + { + type: 'custom', + name: 'ResolveRecursiveValue', + generics: [ + { + type: 'custom', + name: 'TItem', + }, + { + type: 'custom', + name: 'TRoot', + }, + ], + }, + ], + }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveMarker/index.mdx b/website/src/routes/api/(types)/RecursiveMarker/index.mdx new file mode 100644 index 000000000..b221cee29 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveMarker/index.mdx @@ -0,0 +1,17 @@ +--- +title: RecursiveMarker +description: Recursive marker type. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveMarker + +Recursive marker type. + +## Definition + +- `RecursiveMarker` diff --git a/website/src/routes/api/(types)/RecursiveMarker/properties.ts b/website/src/routes/api/(types)/RecursiveMarker/properties.ts new file mode 100644 index 000000000..1eabf0456 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveMarker/properties.ts @@ -0,0 +1,11 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + RecursiveMarker: { + type: { + type: 'custom', + modifier: 'typeof', + name: 'RecursiveMarkerSymbol', + }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx b/website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx new file mode 100644 index 000000000..2ff821d0c --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx @@ -0,0 +1,22 @@ +--- +title: RecursiveReadonlyArray +description: Recursive readonly array interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveReadonlyArray + +Recursive readonly array interface. + +## Generics + +- `TItem` +- `TRoot` + +## Definition + +- `RecursiveReadonlyArray` diff --git a/website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts b/website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts new file mode 100644 index 000000000..10c6a2e5f --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts @@ -0,0 +1,35 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TItem: { + modifier: 'extends', + type: 'any', + }, + TRoot: { + modifier: 'extends', + type: 'any', + }, + ReadonlyArray: { + modifier: 'extends', + type: { + type: 'custom', + name: 'ReadonlyArray', + generics: [ + { + type: 'custom', + name: 'ResolveRecursiveValue', + generics: [ + { + type: 'custom', + name: 'TItem', + }, + { + type: 'custom', + name: 'TRoot', + }, + ], + }, + ], + }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveSchema/properties.ts b/website/src/routes/api/(types)/RecursiveSchema/properties.ts index e666ee9b4..3d7af8e4f 100644 --- a/website/src/routes/api/(types)/RecursiveSchema/properties.ts +++ b/website/src/routes/api/(types)/RecursiveSchema/properties.ts @@ -103,6 +103,7 @@ export const properties: Record = { type: { type: 'custom', name: 'RecursiveSelfSchema', + href: '../RecursiveSelfSchema/', }, }, ], diff --git a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts index dc8a07b80..61ae81fb4 100644 --- a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts +++ b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts @@ -134,6 +134,7 @@ export const properties: Record = { type: { type: 'custom', name: 'RecursiveSelfSchemaAsync', + href: '../RecursiveSelfSchemaAsync/', }, }, ], diff --git a/website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx b/website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx new file mode 100644 index 000000000..113cb5e7b --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx @@ -0,0 +1,18 @@ +--- +title: RecursiveSelfSchema +description: Recursive self schema interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveSelfSchema + +Recursive self schema interface. + +## Definition + +- `RecursiveSelfSchema` + - `type` diff --git a/website/src/routes/api/(types)/RecursiveSelfSchema/properties.ts b/website/src/routes/api/(types)/RecursiveSelfSchema/properties.ts new file mode 100644 index 000000000..c4cad0b49 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSelfSchema/properties.ts @@ -0,0 +1,31 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + BaseSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + { + type: 'custom', + name: 'RecursiveMarker', + href: '../RecursiveMarker/', + }, + { + type: 'custom', + name: 'RecursiveMarker', + href: '../RecursiveMarker/', + }, + 'never', + ], + }, + }, + type: { + type: { + type: 'string', + value: 'recursive', + }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx b/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx new file mode 100644 index 000000000..d50c483d6 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx @@ -0,0 +1,18 @@ +--- +title: RecursiveSelfSchemaAsync +description: Recursive self schema async interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveSelfSchemaAsync + +Recursive self schema async interface. + +## Definition + +- `RecursiveSelfSchemaAsync` + - `type` diff --git a/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/properties.ts b/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/properties.ts new file mode 100644 index 000000000..ad5e314c5 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/properties.ts @@ -0,0 +1,31 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + BaseSchemaAsync: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchemaAsync', + href: '../BaseSchemaAsync/', + generics: [ + { + type: 'custom', + name: 'RecursiveMarker', + href: '../RecursiveMarker/', + }, + { + type: 'custom', + name: 'RecursiveMarker', + href: '../RecursiveMarker/', + }, + 'never', + ], + }, + }, + type: { + type: { + type: 'string', + value: 'recursive', + }, + }, +}; diff --git a/website/src/routes/api/menu.md b/website/src/routes/api/menu.md index e63e6a9da..520d0e8cc 100644 --- a/website/src/routes/api/menu.md +++ b/website/src/routes/api/menu.md @@ -670,8 +670,13 @@ - [RecordIssue](/api/RecordIssue/) - [RecordSchema](/api/RecordSchema/) - [RecordSchemaAsync](/api/RecordSchemaAsync/) +- [RecursiveArray](/api/RecursiveArray/) +- [RecursiveMarker](/api/RecursiveMarker/) +- [RecursiveReadonlyArray](/api/RecursiveReadonlyArray/) - [RecursiveSchema](/api/RecursiveSchema/) - [RecursiveSchemaAsync](/api/RecursiveSchemaAsync/) +- [RecursiveSelfSchema](/api/RecursiveSelfSchema/) +- [RecursiveSelfSchemaAsync](/api/RecursiveSelfSchemaAsync/) - [ReduceItemsAction](/api/ReduceItemsAction/) - [Reference](/api/Reference/) - [RegexAction](/api/RegexAction/) From 6a562d018ea88ba27334b669869b2517ed2fd28e Mon Sep 17 00:00:00 2001 From: James Tindal Date: Fri, 6 Nov 2026 01:12:11 +0100 Subject: [PATCH 09/39] test: expand recursive schema test coverage --- .../src/schemas/recursive/recursive.test-d.ts | 95 ++++++++++++++++- .../recursive/recursiveAsync.test-d.ts | 100 ++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index c57686c47..28ab269bb 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -1,16 +1,21 @@ import { describe, expectTypeOf, test } from 'vitest'; -import { transform } from '../../actions/index.ts'; +import { readonly, transform } from '../../actions/index.ts'; import { pipe } from '../../methods/index.ts'; import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; import { array, type ArrayIssue, type ArraySchema } from '../array/index.ts'; import { instance } from '../instance/index.ts'; +import { intersect } from '../intersect/index.ts'; +import { literal } from '../literal/index.ts'; import { map } from '../map/index.ts'; +import { nullable } from '../nullable/index.ts'; +import { number } from '../number/index.ts'; import { object, type ObjectIssue, type ObjectSchema, } from '../object/index.ts'; import { optional } from '../optional/index.ts'; +import { record } from '../record/index.ts'; import { set } from '../set/index.ts'; import { string, @@ -20,6 +25,7 @@ import { import { tuple } from '../tuple/index.ts'; import { tupleWithRest } from '../tupleWithRest/index.ts'; import { union, type UnionIssue, type UnionSchema } from '../union/index.ts'; +import { variant } from '../variant/index.ts'; import { type GenericRecursiveSchema, recursive, @@ -276,6 +282,93 @@ describe('recursive', () => { >(); }); + test('should resolve transforms and defaults at every recursive level', () => { + const Schema = recursive((self) => + object({ + value: pipe(string(), transform(Number)), + label: optional(string(), 'default'), + children: array(self), + }) + ); + + interface Input { + value: string; + label?: string | undefined; + children: Input[]; + } + interface Output { + value: number; + label: string; + children: Output[]; + } + + expectTypeOf(Schema).toHaveProperty('~run'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf<{ + value: string; + children: { value: number; children: [] }[]; + }>().not.toExtend>(); + expectTypeOf<{ + value: number; + label: string; + children: { value: number; children: [] }[]; + }>().not.toExtend>(); + }); + + test('should resolve nullable records and readonly recursive arrays', () => { + const Schema = recursive((self) => + object({ + children: record(string(), nullable(self)), + siblings: pipe(array(self), readonly()), + pair: tuple([string(), optional(self)]), + }) + ); + + interface Input { + children: Record; + siblings: Input[]; + pair: [string, Input | undefined]; + } + interface Output { + children: Record; + readonly siblings: readonly Output[]; + pair: [string, Output | undefined]; + } + + expectTypeOf(Schema).toHaveProperty('~run'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should resolve recursive variants inside intersections', () => { + const Schema = recursive((self) => + intersect([ + object({ id: number() }), + variant('kind', [ + object({ kind: literal('leaf'), value: string() }), + object({ kind: literal('branch'), children: array(self) }), + ]), + ]) + ); + + type Node = { id: number } & ( + | { kind: 'leaf'; value: string } + | { kind: 'branch'; children: Node[] } + ); + + expectTypeOf(Schema).toHaveProperty('~run'); + expectTypeOf>().toExtend(); + expectTypeOf().toExtend>(); + expectTypeOf>().toExtend(); + expectTypeOf().toExtend>(); + expectTypeOf<{ + id: number; + kind: 'branch'; + children: { id: number; kind: 'leaf'; value: number }[]; + }>().not.toExtend>(); + }); + test('should support generic schema interface', () => { const Category = recursive((self) => object({ diff --git a/library/src/schemas/recursive/recursiveAsync.test-d.ts b/library/src/schemas/recursive/recursiveAsync.test-d.ts index 99dca9ac2..6721fb752 100644 --- a/library/src/schemas/recursive/recursiveAsync.test-d.ts +++ b/library/src/schemas/recursive/recursiveAsync.test-d.ts @@ -1,4 +1,6 @@ import { describe, expectTypeOf, test } from 'vitest'; +import { readonly, transformAsync } from '../../actions/index.ts'; +import { pipeAsync } from '../../methods/index.ts'; import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; import { arrayAsync, @@ -6,19 +8,27 @@ import { type ArraySchemaAsync, } from '../array/index.ts'; import { instance } from '../instance/index.ts'; +import { intersectAsync } from '../intersect/index.ts'; +import { literal } from '../literal/index.ts'; import { mapAsync } from '../map/index.ts'; +import { nullableAsync } from '../nullable/index.ts'; +import { number } from '../number/index.ts'; import { objectAsync, type ObjectIssue, type ObjectSchemaAsync, } from '../object/index.ts'; +import { optional, optionalAsync } from '../optional/index.ts'; +import { recordAsync } from '../record/index.ts'; import { setAsync } from '../set/index.ts'; import { string, type StringIssue, type StringSchema, } from '../string/index.ts'; +import { tupleAsync } from '../tuple/index.ts'; import type { UnionIssue, UnionSchemaAsync } from '../union/index.ts'; +import { variantAsync } from '../variant/index.ts'; import { recursiveAsync, type RecursiveSchemaAsync, @@ -148,6 +158,96 @@ describe('recursiveAsync', () => { >(); }); + test('should resolve async transforms and defaults at every recursive level', () => { + const Schema = recursiveAsync(async (self) => + objectAsync({ + value: pipeAsync( + string(), + transformAsync(async (value) => Number(value)) + ), + label: optional(string(), 'default'), + children: arrayAsync(self), + }) + ); + + interface Input { + value: string; + label?: string | undefined; + children: Input[]; + } + interface Output { + value: number; + label: string; + children: Output[]; + } + + expectTypeOf(Schema).toHaveProperty('~run'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf<{ + value: string; + children: { value: number; children: [] }[]; + }>().not.toExtend>(); + expectTypeOf<{ + value: number; + label: string; + children: { value: number; children: [] }[]; + }>().not.toExtend>(); + }); + + test('should resolve nullable records and readonly arrays in async schemas', () => { + const Schema = recursiveAsync((self) => + objectAsync({ + children: recordAsync(string(), nullableAsync(self)), + siblings: pipeAsync(arrayAsync(self), readonly()), + pair: tupleAsync([string(), optionalAsync(self)]), + }) + ); + + interface Input { + children: Record; + siblings: Input[]; + pair: [string, Input | undefined]; + } + interface Output { + children: Record; + readonly siblings: readonly Output[]; + pair: [string, Output | undefined]; + } + + expectTypeOf(Schema).toHaveProperty('~run'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('should resolve recursive async variants inside intersections', () => { + const Schema = recursiveAsync((self) => + intersectAsync([ + objectAsync({ id: number() }), + variantAsync('kind', [ + objectAsync({ kind: literal('leaf'), value: string() }), + objectAsync({ kind: literal('branch'), children: arrayAsync(self) }), + ]), + ]) + ); + + type Node = { id: number } & ( + | { kind: 'leaf'; value: string } + | { kind: 'branch'; children: Node[] } + ); + + expectTypeOf(Schema).toHaveProperty('~run'); + expectTypeOf>().toExtend(); + expectTypeOf().toExtend>(); + expectTypeOf>().toExtend(); + expectTypeOf().toExtend>(); + expectTypeOf<{ + id: number; + kind: 'branch'; + children: { id: number; kind: 'leaf'; value: number }[]; + }>().not.toExtend>(); + }); + test('should support schema interface', () => { type Schema = RecursiveSchemaAsync< ObjectSchemaAsync< From 6ea2946abffca9648dbb4f24527ec19c2d3bb0e8 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Fri, 6 Nov 2026 01:12:11 +0100 Subject: [PATCH 10/39] fix: preserve recursive markers through transforms --- library/src/schemas/recursive/recursive.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index b63c707c5..f33e1d17b 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -16,7 +16,9 @@ export declare const RecursiveMarkerSymbol: unique symbol; /** * Recursive marker type. */ -export type RecursiveMarker = typeof RecursiveMarkerSymbol; +export interface RecursiveMarker { + readonly [RecursiveMarkerSymbol]: true; +} /** * Recursive self schema interface. From 6f1167e3d2720dcd6daa0953b765aed2e07e5bde Mon Sep 17 00:00:00 2001 From: James Tindal Date: Sun, 8 Nov 2026 21:18:58 +0100 Subject: [PATCH 11/39] feat: add mutual recursion and strengthen recursive schemas --- library/src/methods/flatten/flatten.test-d.ts | 4 +- library/src/schemas/index.ts | 1 + library/src/schemas/mutualRecursive/index.ts | 14 + .../mutualRecursive.errors.test.ts | 153 +++++ .../mutualRecursive/mutualRecursive.test-d.ts | 567 ++++++++++++++++ .../mutualRecursive/mutualRecursive.test.ts | 401 +++++++++++ .../mutualRecursive/mutualRecursive.ts | 636 ++++++++++++++++++ .../mutualRecursiveAsync.errors.test.ts | 141 ++++ .../mutualRecursiveAsync.execution.test.ts | 216 ++++++ .../mutualRecursiveAsync.test-d.ts | 570 ++++++++++++++++ .../mutualRecursiveAsync.test.ts | 216 ++++++ .../mutualRecursive/mutualRecursiveAsync.ts | 158 +++++ library/src/schemas/recursive/index.ts | 11 +- .../recursive/recursive.compiler.test.ts | 84 +++ .../src/schemas/recursive/recursive.test-d.ts | 62 +- .../src/schemas/recursive/recursive.test.ts | 117 ++-- library/src/schemas/recursive/recursive.ts | 497 +++++++++++--- .../recursiveAsync.execution.test.ts | 147 ++++ .../recursive/recursiveAsync.test-d.ts | 62 +- .../schemas/recursive/recursiveAsync.test.ts | 95 ++- .../src/schemas/recursive/recursiveAsync.ts | 113 ++-- library/src/types/issue.ts | 15 +- .../src/utils/getDotPath/getDotPath.test-d.ts | 4 +- .../convertSchema/convertSchema.test.ts | 43 +- .../converters/convertSchema/convertSchema.ts | 43 +- .../api/(async)/recursiveAsync/properties.ts | 4 +- .../api/(schemas)/recursive/properties.ts | 4 +- .../GenericRecursiveSchema/properties.ts | 4 +- .../(types)/RecursiveMarkerSchema/index.mdx | 18 + .../properties.ts | 0 .../RecursiveMarkerSchemaAsync/index.mdx | 18 + .../properties.ts | 0 .../api/(types)/RecursiveSchema/properties.ts | 4 +- .../RecursiveSchemaAsync/properties.ts | 4 +- .../api/(types)/RecursiveSelfSchema/index.mdx | 18 - .../RecursiveSelfSchemaAsync/index.mdx | 18 - website/src/routes/api/menu.md | 4 +- 37 files changed, 4142 insertions(+), 324 deletions(-) create mode 100644 library/src/schemas/mutualRecursive/index.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursive.errors.test.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursive.test.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursive.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursiveAsync.errors.test.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts create mode 100644 library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts create mode 100644 library/src/schemas/recursive/recursive.compiler.test.ts create mode 100644 library/src/schemas/recursive/recursiveAsync.execution.test.ts create mode 100644 website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx rename website/src/routes/api/(types)/{RecursiveSelfSchema => RecursiveMarkerSchema}/properties.ts (100%) create mode 100644 website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx rename website/src/routes/api/(types)/{RecursiveSelfSchemaAsync => RecursiveMarkerSchemaAsync}/properties.ts (100%) delete mode 100644 website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx delete mode 100644 website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx diff --git a/library/src/methods/flatten/flatten.test-d.ts b/library/src/methods/flatten/flatten.test-d.ts index 5306e4c2e..7e708ca84 100644 --- a/library/src/methods/flatten/flatten.test-d.ts +++ b/library/src/methods/flatten/flatten.test-d.ts @@ -8,7 +8,7 @@ import type { StringIssue, StringSchema, } from '../../schemas/index.ts'; -import type { RecursiveSelfSchema } from '../../schemas/recursive/recursive.ts'; +import type { Ref } from '../../schemas/recursive/recursive.ts'; import type { ArrayPathItem, ObjectPathItem } from '../../types/index.ts'; import { flatten } from './flatten.ts'; @@ -127,7 +127,7 @@ describe('flatten', () => { ObjectSchema< { name: StringSchema; - subcategories: ArraySchema; + subcategories: ArraySchema; }, undefined > diff --git a/library/src/schemas/index.ts b/library/src/schemas/index.ts index c4c285753..c56dd5fc0 100644 --- a/library/src/schemas/index.ts +++ b/library/src/schemas/index.ts @@ -31,6 +31,7 @@ export * from './optional/index.ts'; export * from './picklist/index.ts'; export * from './promise/index.ts'; export * from './recursive/index.ts'; +export * from './mutualRecursive/index.ts'; export * from './record/index.ts'; export * from './set/index.ts'; export * from './strictObject/index.ts'; diff --git a/library/src/schemas/mutualRecursive/index.ts b/library/src/schemas/mutualRecursive/index.ts new file mode 100644 index 000000000..705f2b4bc --- /dev/null +++ b/library/src/schemas/mutualRecursive/index.ts @@ -0,0 +1,14 @@ +export { + mutualRecursive, + type MutualRecursiveSchema, +} from './mutualRecursive.ts'; +export type { + RefFactory, + RefFactoryAsync, + ResolvedRecursiveSchema as ResolvedMutualRecursiveSchema, +} from './mutualRecursive.ts'; +export { + mutualRecursiveAsync, + type MutualRecursiveSchemaAsync, + type ResolvedRecursiveSchemaAsync as ResolvedMutualRecursiveSchemaAsync, +} from './mutualRecursiveAsync.ts'; diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.errors.test.ts b/library/src/schemas/mutualRecursive/mutualRecursive.errors.test.ts new file mode 100644 index 000000000..94bd0a53d --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursive.errors.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import * as ts from 'typescript'; +import { beforeAll, describe, expect, test } from 'vitest'; + +const fixture = ts.sys.resolvePath( + 'src/schemas/mutualRecursive/mutualRecursive-error.fixture.ts' +); + +/** + * Compiles the test cases in one virtual file. + * + * @param body The TypeScript source for the test cases. + * @param async Whether to compile the async equivalents. + * + * @returns The source and compiler errors. + */ +function compile(body: string, async: boolean) { + const imports = async + ? 'recursiveAsync as recursive, objectAsync as object, arrayAsync as array, optionalAsync as optional, string, pipeAsync as pipe, transformAsync as transform' + : 'recursive, object, array, optional, string, pipe, transform'; + const source = `import { ${imports.replace('recursive, ', '')} } from '../../index.ts';\nimport { mutualRecursive as recursive } from './mutualRecursive.ts';\n${body}`; + const options: ts.CompilerOptions = { + strict: true, + exactOptionalPropertyTypes: true, + skipLibCheck: true, + noEmit: true, + target: ts.ScriptTarget.ES2020, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + allowImportingTsExtensions: true, + }; + const host = ts.createCompilerHost(options); + const readFile = host.readFile; + host.readFile = (path) => (path === fixture ? source : readFile(path)); + const program = ts.createProgram([fixture], options, host); + const fixtureSource = program.getSourceFile(fixture); + const implementationSource = program.getSourceFile( + ts.sys.resolvePath('src/schemas/mutualRecursive/mutualRecursive.ts') + ); + if (!fixtureSource || !implementationSource) { + throw new Error('Expected the fixture and mutualRecursive source files.'); + } + const diagnostics = [ + ...program.getOptionsDiagnostics(), + ...program.getGlobalDiagnostics(), + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(fixtureSource), + ...program.getSemanticDiagnostics(implementationSource), + ]; + return { source, diagnostics }; +} + +describe.each([false])( + 'recursive compiler diagnostics (async: %s)', + (async) => { + const valid = `const schemas = recursive((ref) => ({ + a: object({ bs: array(ref('b')) }), + b: object({ parent: optional(ref('a')) }), +}));`; + const missingLabel = `recursive((ref) => ({ + a: object({ bs: array(ref('b')), bad: ref() }), + b: object({ parent: optional(ref('a')) }), +}));`; + const unmatched = [ + { + name: 'an unmatched reference in a mutually recursive input and output', + body: `recursive((ref) => ({ + a: object({ bs: array(ref('b')), bad: ref('typo') }), + b: object({ parent: optional(ref('a')) }), +}));`, + }, + { + name: 'an unmatched input reference removed by a transform in mutual recursion', + body: `recursive((ref) => ({ + a: pipe( + object({ bs: array(ref('b')), bad: ref('typo') }), + transform(${async ? 'async ' : ''}(value) => ({ bs: value.bs })) + ), + b: object({ parent: optional(ref('a')) }), +}));`, + }, + ]; + let compiled: ReturnType; + + beforeAll(() => { + compiled = compile( + [valid, ...unmatched.map(({ body }) => body), missingLabel].join('\n'), + async + ); + expect(compiled.diagnostics).toHaveLength(3); + }, 60_000); + + /** + * Selects the compiler errors for one test case. + * + * @param body The TypeScript source for the test case. + * + * @returns The compiler errors within the test case. + */ + function diagnosticsFor(body: string) { + const start = compiled.source.indexOf(body); + expect(start).toBeGreaterThanOrEqual(0); + return compiled.diagnostics.filter( + (diagnostic) => + diagnostic.file?.fileName === fixture && + diagnostic.start !== undefined && + diagnostic.start >= start && + diagnostic.start < start + body.length + ); + } + + test('accepts two definitions that reference each other', () => { + expect(diagnosticsFor(valid)).toEqual([]); + }); + + test.each(unmatched)( + 'rejects $name with the exact labelled error type', + ({ body }) => { + const { source } = compiled; + const diagnostics = diagnosticsFor(body); + expect(diagnostics).toHaveLength(1); + const diagnostic = diagnostics[0]; + expect(diagnostic.file?.fileName).toBe(fixture); + expect(diagnostic.category).toBe(ts.DiagnosticCategory.Error); + expect(diagnostic.code).toBe(2322); + expect( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ).toContain(`Error<"Unmatched recursive reference: 'typo'">`); + const start = source.indexOf('({\n a:', source.indexOf(body)); + expect(diagnostic.start).toBe(start); + expect(diagnostic.length).toBe( + body.slice(body.indexOf('({'), body.lastIndexOf(')')).length + ); + }, + 60_000 + ); + + test('rejects a reference call without a label at that call', () => { + const { source } = compiled; + const diagnostics = diagnosticsFor(missingLabel); + expect(diagnostics).toHaveLength(1); + const diagnostic = diagnostics[0]; + expect(diagnostic.file?.fileName).toBe(fixture); + expect(diagnostic.code).toBe(2554); + expect( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ).toBe('Expected 1 arguments, but got 0.'); + expect(diagnostic.start).toBe(source.indexOf('ref()')); + expect(diagnostic.length).toBe(3); + }, 60_000); + } +); diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts b/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts new file mode 100644 index 000000000..f8b56c89d --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts @@ -0,0 +1,567 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import { readonly, transform } from '../../actions/index.ts'; +import { pipe } from '../../methods/index.ts'; +import type { + BaseIssue, + BaseSchema, + GenericSchema, + InferInput, + InferIssue, + InferOutput, +} from '../../types/index.ts'; +import { array, type ArrayIssue } from '../array/index.ts'; +import { instance } from '../instance/index.ts'; +import { intersect } from '../intersect/index.ts'; +import { literal } from '../literal/index.ts'; +import { map } from '../map/index.ts'; +import { nullable } from '../nullable/index.ts'; +import { number, type NumberIssue } from '../number/index.ts'; +import { object, type ObjectIssue } from '../object/index.ts'; +import { objectAsync } from '../object/objectAsync.ts'; +import { optional } from '../optional/index.ts'; +import { record } from '../record/index.ts'; +import { set } from '../set/index.ts'; +import { string, type StringIssue } from '../string/index.ts'; +import { tuple } from '../tuple/index.ts'; +import { tupleWithRest } from '../tupleWithRest/index.ts'; +import { union, type UnionIssue } from '../union/index.ts'; +import { variant } from '../variant/index.ts'; +import { + mutualRecursive, + type MutualRecursiveSchema, + type RecursiveMarker, + type RecursiveReferences, + type RefFactory, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, +} from './mutualRecursive.ts'; + +describe('mutualRecursive', () => { + const definition = (ref: RefFactory) => ({ + a: object({ name: string(), bs: array(ref('b')) }), + b: object({ score: number(), parent: optional(ref('a')) }), + }); + const schemas = mutualRecursive(definition); + interface A { + name: string; + bs: B[]; + } + interface B { + score: number; + parent?: A | undefined; + } + type Issues = ObjectIssue | ArrayIssue | StringIssue | NumberIssue; + + test('preserves exact definition names', () => { + expectTypeOf().toEqualTypeOf<'a' | 'b'>(); + + // @ts-expect-error Only declared definitions are returned. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- This access checks that an undeclared name is rejected. + schemas.missing; + }); + + test('returns a schema with the exact wrapped definition at each name', () => { + expectTypeOf(schemas.a).toEqualTypeOf< + MutualRecursiveSchema['a'], A, A, Issues> + >(); + expectTypeOf(schemas.b).toEqualTypeOf< + MutualRecursiveSchema['b'], B, B, Issues> + >(); + expectTypeOf(schemas.a.wrapped).toEqualTypeOf< + ReturnType['a'] + >(); + expectTypeOf(schemas.a).toExtend(); + expectTypeOf(schemas.a.async).toEqualTypeOf(); + expectTypeOf(schemas.a.type).toEqualTypeOf<'mutualRecursive'>(); + }); + + test('infers input and output for every root', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves independent self-recursion and mutual recursion', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ children: array(ref('a')), links: array(ref('b')) }), + b: object({ children: array(ref('b')), parent: optional(ref('a')) }), + })); + interface A { + children: A[]; + links: B[]; + } + interface B { + children: B[]; + parent?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves cycles through three definitions', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ b: optional(ref('b')) }), + b: object({ c: optional(ref('c')) }), + c: object({ a: optional(ref('a')) }), + })); + interface A { + b?: B | undefined; + } + interface B { + c?: C | undefined; + } + interface C { + a?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves transformations and defaults at every level', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ items: array(ref('b')) }), + b: object({ + value: pipe(string(), transform(Number)), + label: optional(string(), 'default'), + parent: optional(ref('a')), + }), + })); + interface InputA { + items: InputB[]; + } + interface InputB { + value: string; + label?: string | undefined; + parent?: InputA | undefined; + } + interface OutputA { + items: OutputB[]; + } + interface OutputB { + value: number; + label: string; + parent?: OutputA | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf<{ items: { value: number }[] }>().not.toExtend< + InferInput + >(); + expectTypeOf<{ items: { value: string; label: string }[] }>().not.toExtend< + InferOutput + >(); + expectTypeOf<{ items: { value: number }[] }>().not.toExtend< + InferOutput + >(); + }); + + test('resolves whole-object transformations across definitions', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ items: array(ref('b')) }), + b: pipe( + object({ parent: optional(ref('a')) }), + transform((value) => ({ ...value, label: 'item' as const })) + ), + })); + interface InputA { + items: InputB[]; + } + interface InputB { + parent?: InputA | undefined; + } + interface OutputA { + items: OutputB[]; + } + interface OutputB { + parent?: OutputA | undefined; + label: 'item'; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('preserves recursion when composing a selected root', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const selected = pipe( + schemas.a, + transform((value) => value.bs) + ); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferInput>> + >().toEqualTypeOf(); + }); + + test('resolves containers and readonly arrays', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ + records: record(string(), nullable(ref('b'))), + maps: map(ref('b'), ref('b')), + sets: set(ref('b')), + pair: tuple([string(), optional(ref('b'))]), + children: pipe(array(ref('b')), readonly()), + }), + b: object({ parent: optional(ref('a')) }), + })); + interface InputA { + records: Record; + maps: Map; + sets: Set; + pair: [string, InputB | undefined]; + children: InputB[]; + } + interface InputB { + parent?: InputA | undefined; + } + interface OutputA { + records: Record; + maps: Map; + sets: Set; + pair: [string, OutputB | undefined]; + readonly children: readonly OutputB[]; + } + interface OutputB { + parent?: OutputA | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves recursive tuples and variadic tuples', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: union([tuple([string()]), tuple([string(), ref('b')])]), + b: tupleWithRest([number()], ref('a')), + })); + type A = [string] | [string, B]; + type B = [number, ...A[]]; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves unions without an enclosing object', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: union([string(), array(ref('b'))]), + b: union([string(), array(ref('a'))]), + })); + type A = string | B[]; + type B = string | A[]; + type Issues = + | StringIssue + | ArrayIssue + | UnionIssue; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves variants inside intersections', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + node: intersect([ + object({ id: number() }), + variant('kind', [ + object({ kind: literal('leaf'), value: string() }), + object({ kind: literal('branch'), children: array(ref('node')) }), + ]), + ]), + root: object({ node: ref('node') }), + })); + type Node = { id: number } & ( + | { kind: 'leaf'; value: string } + | { kind: 'branch'; children: Node[] } + ); + expectTypeOf>().toExtend(); + expectTypeOf().toExtend>(); + expectTypeOf>().toExtend<{ node: Node }>(); + expectTypeOf<{ node: Node }>().toExtend>(); + expectTypeOf<{ + id: number; + kind: 'branch'; + children: { id: number; kind: 'leaf'; value: number }[]; + }>().not.toExtend>(); + }); + + test('preserves non-recursive instances and branded container subclasses', () => { + class User { + private readonly _brand!: 'User'; + } + class CustomMap extends Map { + private readonly _brand!: 'CustomMap'; + } + class CustomSet extends Set { + private readonly _brand!: 'CustomSet'; + } + class CustomArray extends Array { + private readonly _brand!: 'CustomArray'; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ + user: instance(User), + url: instance(URL), + error: instance(Error), + bytes: instance(Uint8Array), + maps: instance(CustomMap), + sets: instance(CustomSet), + arrays: instance(CustomArray), + next: optional(ref('a')), + }), + })); + interface A { + user: User; + url: URL; + error: Error; + bytes: InstanceType; + maps: CustomMap; + sets: CustomSet; + arrays: CustomArray; + next?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('keeps nested independent definitions independent', () => { + const inner = mutualRecursive((ref) => ({ + tag: object({ aliases: array(ref('tag')) }), + })); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const outer = mutualRecursive((ref) => ({ + node: object({ children: array(ref('node')), tags: array(inner.tag) }), + })); + interface Tag { + aliases: Tag[]; + } + interface Node { + children: Node[]; + tags: Tag[]; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('preserves names that collide with Object prototype properties', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + constructor: object({ next: optional(ref('toString')) }), + toString: object({ next: optional(ref('constructor')) }), + })); + expectTypeOf().toEqualTypeOf< + 'constructor' | 'toString' + >(); + interface A { + next?: B | undefined; + } + interface B { + next?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves promises, readonly containers, and tuple modifiers', () => { + interface Root { + value: number; + } + interface Definitions { + node: Root; + } + type Marker = RecursiveMarker<'node'>; + interface Input { + promise: Promise; + map: ReadonlyMap; + set: ReadonlySet; + tuple: readonly [name: string, node?: Marker]; + array: readonly Marker[]; + long: [ + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + ]; + } + interface Expected { + promise: Promise; + map: ReadonlyMap; + set: ReadonlySet; + tuple: readonly [name: string, node?: Root]; + array: readonly Root[]; + long: [Root, Root, Root, Root, Root, Root, Root, Root, Root]; + } + expectTypeOf< + ResolveRecursiveInput + >().toEqualTypeOf(); + expectTypeOf< + ResolveRecursiveOutput + >().toEqualTypeOf(); + }); + + test('collects no references from any, unknown, or never', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('collects concrete references beside any and unknown properties', () => { + expectTypeOf< + RecursiveReferences<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + arbitrary: any; + unknown: unknown; + next: RecursiveMarker<'node'>; + }> + >().toEqualTypeOf>(); + }); + + test('collects references through containers and cyclic objects', () => { + interface Cycle { + next?: Cycle; + reference: RecursiveMarker<'cycle'>; + } + interface Containers { + optional?: RecursiveMarker<'optional'>; + array: RecursiveMarker<'array'>[]; + tuple: readonly [RecursiveMarker<'tuple'>?]; + map: ReadonlyMap, RecursiveMarker<'value'>>; + set: ReadonlySet>; + promise: Promise>; + cycle: Cycle; + } + expectTypeOf>().toEqualTypeOf< + | RecursiveMarker<'optional'> + | RecursiveMarker<'array'> + | RecursiveMarker<'tuple'> + | RecursiveMarker<'key'> + | RecursiveMarker<'value'> + | RecursiveMarker<'set'> + | RecursiveMarker<'promise'> + | RecursiveMarker<'cycle'> + >(); + }); + + test('checks concrete output references independently of unknown input', () => { + const valid = {} as BaseSchema< + unknown, + RecursiveMarker<'node'>, + BaseIssue + >; + const invalid = {} as BaseSchema< + unknown, + RecursiveMarker<'missing'>, + BaseIssue + >; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive(() => ({ node: string(), root: valid })); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error Unknown input must not hide a missing output reference. + mutualRecursive(() => ({ node: string(), root: invalid })); + }); + + test('checks concrete input references independently of unknown output', () => { + const valid = {} as BaseSchema< + RecursiveMarker<'node'>, + unknown, + BaseIssue + >; + const invalid = {} as BaseSchema< + RecursiveMarker<'missing'>, + unknown, + BaseIssue + >; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive(() => ({ node: string(), root: valid })); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error Unknown output must not hide a missing input reference. + mutualRecursive(() => ({ node: string(), root: invalid })); + }); + + test('checks sibling definitions independently of an any-typed definition', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + const arbitrary = {} as any; + mutualRecursive((ref) => ({ + arbitrary, + node: object({ next: optional(ref('node')) }), + })); + // @ts-expect-error An any-typed definition must not hide a missing reference. + mutualRecursive((ref) => ({ + arbitrary, + node: object({ next: ref('missing') }), + })); + }); + + test('rejects references to missing definitions', () => { + // @ts-expect-error The referenced name is not declared. + mutualRecursive((ref) => ({ a: object({ next: ref('missing') }) })); + // @ts-expect-error References in input still count after a transform removes them. + mutualRecursive((ref) => ({ + a: pipe( + object({ bad: ref('missing') }), + transform(() => 'done') + ), + })); + mutualRecursive((ref) => ({ + a: object({ + // @ts-expect-error Named references require one argument. + next: ref(), + }), + })); + }); + + test('keeps the reference factory separate from reference schemas', () => { + mutualRecursive((ref) => { + expectTypeOf(ref).toEqualTypeOf(); + // @ts-expect-error A reference factory is not a schema. + array(ref); + // @ts-expect-error A reference factory has no schema execution method. + expectTypeOf(ref['~run']); + return { a: object({ next: optional(ref('a')) }) }; + }); + }); + + test('rejects invalid callback results', () => { + // @ts-expect-error A single schema is not a definitions object. + mutualRecursive(() => string()); + // @ts-expect-error Every definition must be a schema. + mutualRecursive(() => ({ a: 123 })); + // @ts-expect-error A definition callback cannot return undefined. + mutualRecursive(() => undefined); + // @ts-expect-error Async definition callbacks are not supported. + mutualRecursive(async () => ({ a: string() })); + // @ts-expect-error Async schemas are not supported. + mutualRecursive(() => ({ a: objectAsync({}) })); + }); +}); diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.test.ts b/library/src/schemas/mutualRecursive/mutualRecursive.test.ts new file mode 100644 index 000000000..c313a8c6e --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursive.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, expectTypeOf, test, vi } from 'vitest'; +import { readonly, returns, transform } from '../../actions/index.ts'; +import { parse, pipe, safeParse } from '../../methods/index.ts'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { any } from '../any/index.ts'; +import { array, type ArrayIssue } from '../array/index.ts'; +import { lazy } from '../lazy/index.ts'; +import { map } from '../map/index.ts'; +import { nullable } from '../nullable/index.ts'; +import { number, type NumberIssue } from '../number/index.ts'; +import { object, type ObjectIssue } from '../object/index.ts'; +import { optional } from '../optional/index.ts'; +import { record } from '../record/index.ts'; +import { set } from '../set/index.ts'; +import { string, type StringIssue } from '../string/index.ts'; +import { tuple } from '../tuple/index.ts'; +import { mutualRecursive as recursive } from './mutualRecursive.ts'; + +const schemas = recursive((ref) => ({ + outer: object({ + name: string(), + children: array(ref('outer')), + links: array(ref('inner')), + }), + inner: pipe( + object({ + score: pipe( + string(), + transform((value) => value.length) + ), + parent: optional(ref('outer')), + next: nullable(ref('inner')), + }), + transform((value) => ({ ...value, label: 'link' as const })) + ), +})); + +interface OuterInput { + name: string; + children: OuterInput[]; + links: InnerInput[]; +} +interface InnerInput { + score: string; + parent?: OuterInput | undefined; + next: InnerInput | null; +} +interface OuterOutput { + name: string; + children: OuterOutput[]; + links: InnerOutput[]; +} +interface InnerOutput { + score: number; + parent?: OuterOutput | undefined; + next: InnerOutput | null; + label: 'link'; +} + +const input = { + name: 'root', + children: [], + links: [ + { + score: 'one', + parent: { + name: 'parent', + children: [], + links: [{ score: 'two!', next: null }], + }, + next: { score: 'x', next: null }, + }, + ], +}; + +const output = { + name: 'root', + children: [], + links: [ + { + score: 3, + parent: { + name: 'parent', + children: [], + links: [{ score: 4, next: null, label: 'link' }], + }, + next: { score: 1, next: null, label: 'link' }, + label: 'link', + }, + ], +}; + +describe('mutualRecursive', () => { + test('infers distinct recursive input and transformed output for both roots', () => { + expectTypeOf().toEqualTypeOf<'outer' | 'inner'>(); + expectTypeOf< + InferInput + >().toEqualTypeOf(); + expectTypeOf< + InferOutput + >().toEqualTypeOf(); + expectTypeOf< + InferInput + >().toEqualTypeOf(); + expectTypeOf< + InferOutput + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + ObjectIssue | ArrayIssue | StringIssue + >(); + }); + + test('parses alternating outer and inner nodes and transforms every inner node', () => { + expect(parse(schemas.outer, input)).toStrictEqual(output); + expect(parse(schemas.inner, input.links[0])).toStrictEqual(output.links[0]); + }); + + test('keeps independent self-recursive edges on each definition', () => { + expect( + parse(schemas.outer, { + name: 'root', + children: [{ name: 'child', children: [], links: [] }], + links: [], + }) + ).toStrictEqual({ + name: 'root', + children: [{ name: 'child', children: [], links: [] }], + links: [], + }); + }); + + test('constructs all definitions once and does not reconstruct during parsing', () => { + const construct = vi.fn( + (ref: Parameters[0]>[0]) => ({ + a: object({ bs: array(ref('b')) }), + b: object({ as: array(ref('a')) }), + }) + ); + const group = recursive(construct); + expect(construct).toHaveBeenCalledTimes(1); + const value = { bs: [{ as: [{ bs: [] }] }] }; + expect(parse(group.a, value)).toStrictEqual(value); + expect(parse(group.a, value)).toStrictEqual(value); + expect(construct).toHaveBeenCalledTimes(1); + }); + + test('composes a selected root through pipe and array without changing internal references', () => { + const selected = pipe( + schemas.outer, + transform((value) => value.links) + ); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expect(parse(selected, input)).toStrictEqual(output.links); + expect(parse(array(schemas.outer), [input])).toStrictEqual([output]); + }); + + test('implements Standard Schema validation on both roots', () => { + expect(schemas.outer['~standard'].validate(input)).toStrictEqual({ + typed: true, + value: output, + }); + expect(schemas.inner['~standard'].validate(input.links[0])).toStrictEqual({ + typed: true, + value: output.links[0], + }); + }); + + test('preserves the exact path and message for an invalid nested inner value', () => { + const result = safeParse(schemas.outer, { + ...input, + links: [{ score: 'valid', next: { score: 42, next: null } }], + }); + expect(result.success).toBe(false); + expect( + result.issues?.map((issue) => ({ + type: issue.type, + message: issue.message, + path: issue.path?.map((item) => item.key), + })) + ).toStrictEqual([ + { + type: 'string', + message: 'Invalid type: Expected string but received 42', + path: ['links', 0, 'next', 'score'], + }, + ]); + }); + + test('preserves the exact path and message when recursion returns to the outer definition', () => { + const result = safeParse(schemas.outer, { + name: 'root', + children: [], + links: [ + { + score: 'valid', + next: null, + parent: { name: 42, children: [], links: [] }, + }, + ], + }); + expect(result.success).toBe(false); + expect( + result.issues?.map((issue) => ({ + type: issue.type, + message: issue.message, + path: issue.path?.map((item) => item.key), + })) + ).toStrictEqual([ + { + type: 'string', + message: 'Invalid type: Expected string but received 42', + path: ['links', 0, 'parent', 'name'], + }, + ]); + }); + + test('resolves a cycle through three definitions', () => { + const group = recursive((ref) => ({ + a: object({ b: optional(ref('b')) }), + b: object({ c: optional(ref('c')) }), + c: object({ a: optional(ref('a')) }), + })); + interface A { + b?: B | undefined; + } + interface B { + c?: C | undefined; + } + interface C { + a?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expect(parse(group.a, { b: { c: { a: {} } } })).toStrictEqual({ + b: { c: { a: {} } }, + }); + }); + + test('resolves named references through containers, tuples, and readonly output', () => { + const group = recursive((ref) => ({ + a: object({ + records: record(string(), ref('b')), + maps: map(string(), ref('b')), + sets: set(ref('b')), + pair: tuple([number(), optional(ref('b'))]), + children: pipe(array(ref('b')), readonly()), + }), + b: object({ parent: optional(ref('a')) }), + })); + interface A { + records: Record; + maps: Map; + sets: Set; + pair: [number, B | undefined]; + children: B[]; + } + interface B { + parent?: A | undefined; + } + interface OutA { + records: Record; + maps: Map; + sets: Set; + pair: [number, OutB | undefined]; + readonly children: readonly OutB[]; + } + interface OutB { + parent?: OutA | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + const value = { + records: { item: {} }, + maps: new Map([['item', {}]]), + sets: new Set([{}]), + pair: [1, {}], + children: [{}], + }; + expect(parse(group.a, value)).toStrictEqual(value); + }); + + test('includes issues from referenced definitions in the selected root issue type', () => { + const group = recursive((ref) => ({ + a: object({ bs: array(ref('b')) }), + b: object({ score: number(), parent: optional(ref('a')) }), + })); + expectTypeOf>().toEqualTypeOf< + ObjectIssue | ArrayIssue | NumberIssue + >(); + expect(parse(group.a, { bs: [{ score: 1 }] })).toStrictEqual({ + bs: [{ score: 1 }], + }); + }); + + test('accepts definition labels that collide with Object prototype names', () => { + const group = recursive((ref) => ({ + constructor: object({ next: optional(ref('toString')) }), + toString: object({ next: optional(ref('constructor')) }), + })); + expect(parse(group.constructor, { next: { next: {} } })).toStrictEqual({ + next: { next: {} }, + }); + }); + + test('checks references created by a lazy getter during parsing', () => { + const getter = vi.fn(); + // @ts-expect-error Deliberately exercise the runtime check for a late unmatched label. + const group = recursive((ref) => ({ + a: lazy(() => { + getter(); + return ref('missing'); + }), + })); + + expect(getter).not.toHaveBeenCalled(); + expect(() => parse(group.a, {})).toThrowError( + new Error("Unmatched recursive reference: 'missing'") + ); + expect(getter).toHaveBeenCalledOnce(); + }); + + test('passes a plain factory that creates separate reference schemas', () => { + const group = recursive((ref) => { + expect(typeof ref).toBe('function'); + expect(ref).not.toHaveProperty('kind'); + expect(ref).not.toHaveProperty('~run'); + expect(ref).not.toHaveProperty('~standard'); + const next = ref('a'); + expect(typeof next).toBe('object'); + expect(next).toMatchObject({ kind: 'schema', name: 'a', async: false }); + return { a: object({ next: optional(next) }) }; + }); + expect(parse(group.a, { next: {} })).toStrictEqual({ next: {} }); + }); + + test('rejects direct labelled reference execution', () => { + const definition = vi.fn( + (ref: Parameters[0]>[0]) => ({ + a: object({ children: array(ref('b')) }), + b: object({ children: array(ref('a')) }), + }) + ); + const schemas = recursive(definition); + const marker = definition.mock.results[0].value.a.entries.children.item; + const input = { children: [{ children: [] }] }; + const message = 'Recursive reference used outside its recursive schema'; + + expect(() => parse(marker, input)).toThrowError(message); + expect(() => marker['~run']({ value: input }, {})).toThrowError(message); + expect(() => marker['~standard'].validate(input)).toThrowError(message); + expect(parse(schemas.a, input)).toStrictEqual(input); + expect(parse(schemas.b, input)).toStrictEqual(input); + }); + + test('rejects a foreign reference during construction', () => { + const definition = vi.fn( + (ref: Parameters[0]>[0]) => ({ + a: object({ children: array(ref('a')) }), + }) + ); + recursive(definition); + const result = definition.mock.results[0]; + if (result.type !== 'return') { + throw new Error('Expected the recursive definition to return'); + } + const marker = result.value.a.entries.children.item; + + expect(() => + recursive(() => ({ a: object({ children: array(marker) }) })) + ).toThrowError('Recursive reference used outside its recursive schema'); + }); + + test('rejects a foreign reference inside a returns action during construction', () => { + const definition = vi.fn( + (ref: Parameters[0]>[0]) => ({ + a: object({ children: array(ref('a')) }), + }) + ); + recursive(definition); + const result = definition.mock.results[0]; + if (result.type !== 'return') { + throw new Error('Expected the recursive definition to return'); + } + const marker = result.value.a.entries.children.item; + + expect(() => + recursive(() => ({ a: pipe(any(), returns(marker)) })) + ).toThrowError('Recursive reference used outside its recursive schema'); + }); + + test('throws the exact unmatched-reference error during construction', () => { + expect(() => { + // @ts-expect-error Deliberately exercise the runtime check for an unmatched label. + return recursive((ref) => ({ + a: object({ b: ref('b'), bad: ref('typo') }), + b: object({ a: optional(ref('a')) }), + })); + }).toThrowError(new Error("Unmatched recursive reference: 'typo'")); + }); +}); diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.ts b/library/src/schemas/mutualRecursive/mutualRecursive.ts new file mode 100644 index 000000000..9dbe8dfdc --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursive.ts @@ -0,0 +1,636 @@ +import type { + BaseIssue, + BaseSchema, + BaseSchemaAsync, + Config, + GenericSchema, + GenericSchemaAsync, + InferInput, + InferIssue, + InferOutput, + IsAny, + UnknownDataset, +} from '../../types/index.ts'; +import { _standardSchema } from '../../utils/index.ts'; + +/** + * The recursive owner currently running. + * + * @internal + */ +export const OwnerSymbol: unique symbol = Symbol('recursive owner'); + +/** + * Configuration that carries the recursive owner. + * + * @internal + */ +export interface ConfigWithOwner extends Config> { + /** + * The authorized recursive owner. + */ + readonly [OwnerSymbol]?: RecursiveOwner; +} + +// Named interfaces keep recursive array output from collapsing into circular +// type aliases while preserving normal array behavior. +/* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ + +/** + * Recursive array interface. + */ +export interface RecursiveArray + extends Array> {} + +/** + * Recursive readonly array interface. + */ +export interface RecursiveReadonlyArray + extends ReadonlyArray> {} + +/* eslint-enable @typescript-eslint/no-empty-object-type */ + +// Maps each element of a tuple through the resolver, preserving the tuple's +// positional structure (fixed slots, labels, optionality, readonly). +type ResolveRecursiveTuple = { + [TKey in keyof TValue]: ResolveRecursiveValue; +}; + +// Literal tuple branches defer recursive elements without a spread or mapped +// tuple. Optional, readonly, and longer tuples retain the existing resolver. +type ResolveRecursiveFixedTuple< + TValue extends readonly unknown[], + TRoot, +> = TValue extends [infer A] + ? [ResolveRecursiveValue] + : TValue extends [infer A, infer B] + ? [ResolveRecursiveValue, ResolveRecursiveValue] + : TValue extends [infer A, infer B, infer C] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [infer A, infer B, infer C, infer D] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [infer A, infer B, infer C, infer D, infer E] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + infer G, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + infer G, + infer H, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : ResolveRecursiveTuple; + +type ResolveRecursiveArray< + TValue extends readonly unknown[], + TRoot, +> = number extends TValue['length'] + ? // Variadic tuples (e.g. `[string, ...self[]]`) also have a `number` length, + // but unlike plain arrays carry fixed leading or trailing elements, so map + // element-wise to keep those slots intact. + TValue extends + | readonly [unknown, ...unknown[]] + | readonly [...unknown[], unknown] + ? ResolveRecursiveTuple + : TValue extends (infer TItem)[] + ? true extends ( + TItem extends RecursiveMarker ? true : false + ) + ? RecursiveArray + : ResolveRecursiveValue[] + : TValue extends readonly (infer TItem)[] + ? true extends ( + TItem extends RecursiveMarker ? true : false + ) + ? RecursiveReadonlyArray + : readonly ResolveRecursiveValue[] + : never + : ResolveRecursiveFixedTuple; + +// Rebuilds a container with its type arguments resolved. TypeScript cannot +// reconstruct an arbitrary generic with substituted type arguments, so each +// container kind must be enumerated explicitly. This list must cover every +// container a Valibot schema can infer; an unlisted generic (e.g. `WeakMap`) +// falls through to the object branch, which maps over its methods and leaves +// the marker in its type arguments unresolved. +type ResolveRecursiveContainer = + TValue extends Map + ? Map< + ResolveRecursiveValue, + ResolveRecursiveValue + > + : TValue extends ReadonlyMap + ? ReadonlyMap< + ResolveRecursiveValue, + ResolveRecursiveValue + > + : TValue extends Set + ? Set> + : TValue extends ReadonlySet + ? ReadonlySet> + : TValue extends Promise + ? Promise> + : TValue extends readonly unknown[] + ? ResolveRecursiveArray + : TValue extends object + ? { + [TKey in keyof TValue]: ResolveRecursiveValue< + TValue[TKey], + TRoot + >; + } + : TValue; + +type MergeReference = { + [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; +}; + +type ResolveRecursiveValue = + IsAny extends true + ? TValue + : TValue extends RecursiveMarker + ? TKey extends undefined + ? ResolveRecursiveValue + : TKey extends keyof TRoot + ? Exclude extends never + ? ResolveRecursiveValue + : MergeReference< + ResolveRecursiveValue, + ResolveRecursiveContainer< + Omit, + TRoot + > + > + : never + : [RecursiveReferences] extends [never] + ? TValue + : ResolveRecursiveContainer; + +/** + * Resolve recursive input type. + * + * @internal + */ +export type ResolveRecursiveInput< + TValue, + TRoot = TValue, +> = ResolveRecursiveValue; + +/** + * Resolve recursive output type. + * + * @internal + */ +export type ResolveRecursiveOutput< + TValue, + TRoot = TValue, +> = ResolveRecursiveValue; + +/** + * Recursive schema interface. + */ +export interface MutualRecursiveSchema< + TWrapped extends GenericSchema = GenericSchema, + TInput = unknown, + TOutput = unknown, + TIssue extends BaseIssue = BaseIssue, +> extends BaseSchema { + /** + * The schema type. + */ + readonly type: 'mutualRecursive'; + /** + * The schema reference. + */ + readonly reference: typeof createRecursiveSchema; + /** + * The expected property. + */ + readonly expects: 'unknown'; + /** + * The wrapped schema. + */ + readonly wrapped: TWrapped; +} + +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; + +/** + * Common runtime contract for recursive reference owners. + * + * @internal + */ +export interface RecursiveOwner { + /** + * The callback result that references resolve against. + */ + readonly definition: SyncOrAsyncSchema | Record; + /** + * Whether references execute asynchronously. + */ + readonly async: boolean; + /** + * The owner schema factory. + */ + readonly reference: SyncOrAsyncSchema['reference']; +} + +type Entries = Record; + +type DefinitionInputs = { + [TKey in keyof TEntries]: InferInput; +}; +type DefinitionOutputs = { + [TKey in keyof TEntries]: InferOutput; +}; + +/** + * Collects recursive references from a value. + */ +export type RecursiveReferences = + IsAny extends true + ? never + : TValue extends RecursiveMarker + ? TValue + : [TValue] extends [TSeen] + ? never + : TValue extends + | Map + | ReadonlyMap + ? RecursiveReferences + : TValue extends + | Set + | ReadonlySet + | Promise + ? RecursiveReferences + : TValue extends readonly (infer TItem)[] + ? RecursiveReferences + : TValue extends object + ? { + [TKey in keyof TValue]-?: RecursiveReferences< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] + : never; + +type ReferenceNames = + TReference extends RecursiveMarker ? TName : never; + +type UnmatchedReferences = Exclude< + ReferenceNames< + RecursiveReferences< + DefinitionInputs | DefinitionOutputs + > + >, + keyof TEntries +>; + +interface Error { + readonly reason: TReason; + readonly impossible: never; +} + +type CheckReferences = [ + UnmatchedReferences, +] extends [never] + ? unknown + : Error<`Unmatched recursive reference: '${Extract, string>}'`>; + +/** + * Resolved recursive schema interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. +export interface ResolvedRecursiveSchema< + TWrapped extends GenericSchema, + TInputRoot = InferInput, + TOutputRoot = InferOutput, + TIssue extends BaseIssue = InferIssue, +> extends MutualRecursiveSchema< + TWrapped, + ResolveRecursiveInput, TInputRoot>, + ResolveRecursiveOutput, TOutputRoot>, + TIssue + > {} + +type RecursiveSchemas = { + [TName in keyof TEntries]: ResolvedRecursiveSchema< + TEntries[TName], + DefinitionInputs, + DefinitionOutputs, + InferIssue + >; +}; + +/** + * Creates a map of mutually recursive schemas. + * + * @param build The callback that builds the schema entries. It runs once during construction. + * + * @returns A map of recursive schemas. + */ +export function mutualRecursive< + const TEntries extends Record, +>( + build: (ref: RefFactory) => TEntries & CheckReferences +): RecursiveSchemas; +export function mutualRecursive(build: (ref: RefFactory) => Entries): unknown { + const owner = { + async: false, + reference: createRecursiveSchema, + get definition(): Entries { + return definitions; + }, + } satisfies RecursiveOwner; + const definitions = build(createRefFactory(owner)); + const seen = new Set(); + const entries: Record = Object.create(null); + for (const key of Object.keys(definitions)) { + checkRecursiveReferences(definitions[key], owner, seen); + entries[key] = createRecursiveSchema(definitions[key], owner); + } + return entries; +} + +function createRecursiveSchema( + root: GenericSchema, + owner: RecursiveOwner +): MutualRecursiveSchema { + return _standardSchema({ + kind: 'schema', + type: 'mutualRecursive', + reference: createRecursiveSchema, + expects: 'unknown', + async: false, + wrapped: root, + '~run'(dataset, config) { + const executionConfig: ConfigWithOwner = { + ...config, + [OwnerSymbol]: owner, + }; + return root['~run'](dataset, executionConfig); + }, + }); +} + +/** + * Rejects references with a foreign owner or an unmatched definition name. + * + * @param value The schema. + * @param owner The owner of the refs. + * @param seen The schemas already visited. + */ +export function checkRecursiveReferences( + value: unknown, + owner: Pick, + seen: Set = new Set() +): void { + if ( + !value || + (typeof value !== 'object' && typeof value !== 'function') || + seen.has(value) + ) { + return; + } + seen.add(value); + const schema = value as Record; + if (schema.kind === 'schema' || schema.kind === 'transformation') { + if (schema.owner !== undefined && schema.owner !== owner) { + throw Error('Recursive reference used outside its recursive schema'); + } + if (schema.owner === owner) { + const name = schema.name as string | undefined; + if (name === undefined) { + if (owner.definition.kind !== 'schema') { + throw Error( + 'Recursive reference in a definitions object must be named' + ); + } + } else if ( + owner.definition.kind === 'schema' || + !Object.hasOwn(owner.definition, name) + ) { + throw Error(`Unmatched recursive reference: '${name}'`); + } + } + } + for (const key in schema) { + if ( + key !== 'owner' && + key !== 'reference' && + key !== '~standard' && + key !== '~types' && + !( + key === 'wrapped' && + [ + 'recursive', + 'recursiveAsync', + 'mutualRecursive', + 'mutualRecursiveAsync', + ].includes(schema.type as string) + ) + ) { + checkRecursiveReferences(schema[key], owner, seen); + } + } +} + +/** + * Recursive marker symbol. + */ +export declare const RecursiveMarkerSymbol: unique symbol; + +/** + * Recursive marker type. + */ +export interface RecursiveMarker { + readonly [RecursiveMarkerSymbol]: TKey; +} + +/** + * Recursive reference interface. + */ +export interface Ref + extends BaseSchema, RecursiveMarker, never> { + /** + * The schema type. + */ + readonly type: 'ref'; + /** + * The recursive schema that owns the reference. + */ + readonly owner: RecursiveOwner; + /** + * The referenced definition name. + */ + readonly name: TName; +} + +/** + * Async recursive reference interface. + */ +export interface RefAsync + extends BaseSchemaAsync< + RecursiveMarker, + RecursiveMarker, + never + > { + /** + * The schema type. + */ + readonly type: 'refAsync'; + /** + * The recursive schema that owns the reference. + */ + readonly owner: RecursiveOwner; + /** + * The referenced definition name. + */ + readonly name: TName; +} + +/** + * Factory for named synchronous recursive references. + */ +export type RefFactory = ( + name: TName +) => Ref; + +/** + * Factory for named async recursive references. + */ +export type RefFactoryAsync = ( + name: TName +) => RefAsync; + +function createRef( + owner: RecursiveOwner & { readonly async: false }, + name?: TName +): Ref; +function createRef( + owner: RecursiveOwner & { readonly async: true }, + name?: TName +): RefAsync; +function createRef( + owner: RecursiveOwner, + name?: TName +): Ref | RefAsync; +function createRef( + owner: RecursiveOwner, + name?: TName +): Ref | RefAsync { + function run(dataset: UnknownDataset, config: ConfigWithOwner) { + if (config[OwnerSymbol] !== owner) { + throw Error('Recursive reference used outside its recursive schema'); + } + if ( + name !== undefined && + (owner.definition.kind === 'schema' || + !Object.hasOwn(owner.definition, name)) + ) { + throw Error(`Unmatched recursive reference: '${name}'`); + } + const target = + name === undefined + ? (owner.definition as SyncOrAsyncSchema) + : (owner.definition as Record)[name]; + return target['~run'](dataset, config); + } + + // The marker describes a type-level reference, not the delegated runtime value. + // The recursive resolver replaces it with the target schema's input and output. + return _standardSchema({ + kind: 'schema' as const, + type: owner.async ? ('refAsync' as const) : ('ref' as const), + expects: 'unknown', + owner, + async: owner.async, + reference: owner.reference, + name, + '~run': owner.async + ? async (dataset: UnknownDataset, config: ConfigWithOwner) => + run(dataset, config) + : run, + } as Omit | RefAsync, '~standard'>); +} + +/** + * Creates a factory for named synchronous recursive references. + * + * @param owner The owner of the references. + * + * @returns A named reference factory. + * + * @internal + */ +export function createRefFactory( + owner: RecursiveOwner & { readonly async: false } +): RefFactory; +export function createRefFactory( + owner: RecursiveOwner & { readonly async: true } +): RefFactoryAsync; +export function createRefFactory(owner: RecursiveOwner) { + return (name: TName) => + createRef(owner, name); +} diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.errors.test.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.errors.test.ts new file mode 100644 index 000000000..0f3cbe9da --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.errors.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment node +import * as ts from 'typescript'; +import { beforeAll, describe, expect, test } from 'vitest'; + +const fixture = ts.sys.resolvePath( + 'src/schemas/mutualRecursive/mutualRecursiveAsync-error.fixture.ts' +); + +/** + * Compiles the test cases in one virtual file. + * + * @param body The TypeScript source for the test cases. + * @param async Whether to compile the async equivalents. + * + * @returns The source and compiler errors. + */ +function compile(body: string, async: boolean) { + const imports = async + ? 'mutualRecursiveAsync as recursive, objectAsync as object, arrayAsync as array, optionalAsync as optional, string, pipeAsync as pipe, transformAsync as transform' + : 'mutualRecursive as recursive, object, array, optional, string, pipe, transform'; + const source = `import { ${imports} } from '../../index.ts';\n${body}`; + const options: ts.CompilerOptions = { + strict: true, + exactOptionalPropertyTypes: true, + skipLibCheck: true, + noEmit: true, + target: ts.ScriptTarget.ES2020, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + allowImportingTsExtensions: true, + }; + const host = ts.createCompilerHost(options); + const readFile = host.readFile; + host.readFile = (path) => (path === fixture ? source : readFile(path)); + const diagnostics = ts.getPreEmitDiagnostics( + ts.createProgram([fixture], options, host) + ); + return { source, diagnostics }; +} + +describe.each([true])( + 'mutualRecursiveAsync compiler diagnostics (async: %s)', + (async) => { + const valid = `const schemas = recursive((ref) => ({ + a: object({ bs: array(ref('b')) }), + b: object({ parent: optional(ref('a')) }), +}));`; + const missingLabel = `recursive((ref) => ({ + a: object({ bs: array(ref('b')), bad: ref() }), + b: object({ parent: optional(ref('a')) }), +}));`; + const unmatched = [ + { + name: 'an unmatched reference in a mutually recursive input and output', + body: `recursive((ref) => ({ + a: object({ bs: array(ref('b')), bad: ref('typo') }), + b: object({ parent: optional(ref('a')) }), +}));`, + }, + { + name: 'an unmatched input reference removed by a transform in mutual recursion', + body: `recursive((ref) => ({ + a: pipe( + object({ bs: array(ref('b')), bad: ref('typo') }), + transform(${async ? 'async ' : ''}(value) => ({ bs: value.bs })) + ), + b: object({ parent: optional(ref('a')) }), +}));`, + }, + ]; + let compiled: ReturnType; + + beforeAll(() => { + compiled = compile( + [valid, ...unmatched.map(({ body }) => body), missingLabel].join('\n'), + async + ); + expect(compiled.diagnostics).toHaveLength(3); + }, 60_000); + + /** + * Selects the compiler errors for one test case. + * + * @param body The TypeScript source for the test case. + * + * @returns The compiler errors within the test case. + */ + function diagnosticsFor(body: string) { + const start = compiled.source.indexOf(body); + expect(start).toBeGreaterThanOrEqual(0); + return compiled.diagnostics.filter( + (diagnostic) => + diagnostic.file?.fileName === fixture && + diagnostic.start !== undefined && + diagnostic.start >= start && + diagnostic.start < start + body.length + ); + } + + test('accepts two definitions that reference each other', () => { + expect(diagnosticsFor(valid)).toEqual([]); + }); + + test.each(unmatched)( + 'rejects $name with the exact labelled error type', + ({ body }) => { + const { source } = compiled; + const diagnostics = diagnosticsFor(body); + expect(diagnostics).toHaveLength(1); + const diagnostic = diagnostics[0]; + expect(diagnostic.file?.fileName).toBe(fixture); + expect(diagnostic.category).toBe(ts.DiagnosticCategory.Error); + expect(diagnostic.code).toBe(2322); + expect( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ).toContain(`Error<"Unmatched recursive reference: 'typo'">`); + const start = source.indexOf('({\n a:', source.indexOf(body)); + expect(diagnostic.start).toBe(start); + expect(diagnostic.length).toBe( + body.slice(body.indexOf('({'), body.lastIndexOf(')')).length + ); + }, + 60_000 + ); + + test('rejects a reference call without a label at that call', () => { + const { source } = compiled; + const diagnostics = diagnosticsFor(missingLabel); + expect(diagnostics).toHaveLength(1); + const diagnostic = diagnostics[0]; + expect(diagnostic.file?.fileName).toBe(fixture); + expect(diagnostic.code).toBe(2554); + expect( + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ).toBe('Expected 1 arguments, but got 0.'); + expect(diagnostic.start).toBe(source.indexOf('ref()')); + expect(diagnostic.length).toBe(3); + }, 60_000); + } +); diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts new file mode 100644 index 000000000..cc06bf22f --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + checkAsync, + returnsAsync, + transformAsync, +} from '../../actions/index.ts'; +import { parseAsync, pipeAsync, safeParseAsync } from '../../methods/index.ts'; +import { any } from '../any/index.ts'; +import { arrayAsync } from '../array/index.ts'; +import { lazyAsync } from '../lazy/index.ts'; +import { objectAsync } from '../object/index.ts'; +import { optionalAsync } from '../optional/index.ts'; +import { recursiveAsync } from '../recursive/recursiveAsync.ts'; +import { string } from '../string/index.ts'; +import type { RefFactoryAsync } from './mutualRecursive.ts'; +import { mutualRecursive } from './mutualRecursive.ts'; +import { mutualRecursiveAsync } from './mutualRecursiveAsync.ts'; + +const outside = 'Recursive reference used outside its recursive schema'; + +describe('mutualRecursiveAsync execution', () => { + test('creates async wrappers for mixed sync and async definitions once', async () => { + const definition = vi.fn((ref: RefFactoryAsync) => ({ + node: objectAsync({ name: string(), next: optionalAsync(ref('leaf')) }), + leaf: string(), + })); + const group = mutualRecursiveAsync(definition); + expect(Object.getPrototypeOf(group)).toBeNull(); + expect(Object.keys(group)).toStrictEqual(['node', 'leaf']); + for (const key of ['node', 'leaf'] as const) { + expect(group[key]).toMatchObject({ + kind: 'schema', + type: 'mutualRecursiveAsync', + expects: 'unknown', + async: true, + }); + expect(group[key].wrapped).toBe(definition.mock.results[0].value[key]); + expect(group[key]['~standard']).toMatchObject({ + version: 1, + vendor: 'valibot', + }); + } + expect(group.node.reference).toBe(group.leaf.reference); + await expect( + parseAsync(group.node, { name: 'root', next: 'end' }) + ).resolves.toStrictEqual({ name: 'root', next: 'end' }); + await expect( + group.leaf['~run']({ value: 'end' }, {}) + ).resolves.toStrictEqual({ typed: true, value: 'end' }); + expect(definition).toHaveBeenCalledOnce(); + }); + + test('supports an empty definitions object', () => { + expect(Object.keys(mutualRecursiveAsync(() => ({})))).toStrictEqual([]); + }); + + test('accepts __proto__ as an own definition name without prototype mutation', async () => { + const group = mutualRecursiveAsync((ref) => ({ + ['__proto__']: objectAsync({ next: optionalAsync(ref('__proto__')) }), + })); + expect(Object.getPrototypeOf(group)).toBeNull(); + await expect( + parseAsync(group.__proto__, { next: {} }) + ).resolves.toStrictEqual({ next: {} }); + }); + + test('rejects all direct reference execution without mutating parse configuration', async () => { + const definition = vi.fn((ref: RefFactoryAsync) => ({ + a: objectAsync({ children: arrayAsync(ref('b')) }), + b: objectAsync({ children: arrayAsync(ref('a')) }), + })); + const group = mutualRecursiveAsync(definition); + const marker = definition.mock.results[0].value.a.entries.children.item; + const input = { children: [{ children: [] }] }; + const config = { abortEarly: true }; + await expect(parseAsync(marker, input, config)).rejects.toThrow(outside); + await expect(marker['~run']({ value: input }, config)).rejects.toThrow( + outside + ); + await expect(marker['~standard'].validate(input)).rejects.toThrow(outside); + await expect(parseAsync(group.a, input, config)).resolves.toStrictEqual( + input + ); + await expect(parseAsync(marker, input, config)).rejects.toThrow(outside); + expect(Reflect.ownKeys(config)).toStrictEqual(['abortEarly']); + }); + + test('rejects foreign references in schemas and async return actions', () => { + const definition = vi.fn((ref: RefFactoryAsync) => ({ + a: objectAsync({ children: arrayAsync(ref('a')) }), + })); + mutualRecursiveAsync(definition); + const marker = definition.mock.results[0].value.a.entries.children.item; + expect(() => + mutualRecursiveAsync(() => ({ + a: objectAsync({ children: arrayAsync(marker) }), + })) + ).toThrow(outside); + expect(() => + mutualRecursiveAsync(() => ({ + a: pipeAsync(any(), returnsAsync(marker)), + })) + ).toThrow(outside); + }); + + test('checks references created by async lazy getters during parsing', async () => { + const getter = vi.fn(); + // @ts-expect-error Deliberately exercise a late unmatched label. + const group = mutualRecursiveAsync((ref) => ({ + a: lazyAsync(async () => { + getter(); + return ref('missing'); + }), + })); + expect(getter).not.toHaveBeenCalled(); + await expect(parseAsync(group.a, {})).rejects.toThrow( + "Unmatched recursive reference: 'missing'" + ); + expect(getter).toHaveBeenCalledOnce(); + }); + + test('rejects inherited names that are not definitions', () => { + expect(() => + // @ts-expect-error Object prototype properties are not definitions. + mutualRecursiveAsync((ref) => ({ + a: objectAsync({ next: ref('toString') }), + })) + ).toThrow("Unmatched recursive reference: 'toString'"); + }); + + test('preserves nested async validation paths and abort configuration', async () => { + const group = mutualRecursiveAsync((ref) => ({ + a: objectAsync({ items: arrayAsync(ref('b')) }), + b: objectAsync({ + value: pipeAsync( + string(), + checkAsync(async (value) => value !== 'bad', 'invalid value') + ), + parent: optionalAsync(ref('a')), + }), + })); + const input = { + items: [ + { + value: 'ok', + parent: { items: [{ value: 'bad' }, { value: 'bad' }] }, + }, + ], + }; + const result = await safeParseAsync(group.a, input, { abortEarly: true }); + expect(result.issues).toHaveLength(1); + expect(result.issues?.[0]).toMatchObject({ + message: 'invalid value', + abortEarly: true, + }); + expect(result.issues?.[0].path?.map(({ key }) => key)).toStrictEqual([ + 'items', + 0, + 'parent', + 'items', + 0, + 'value', + ]); + const standard = await group.a['~standard'].validate(input); + expect(standard.issues).toHaveLength(2); + }); + + test('propagates async transformation exceptions', async () => { + const error = new Error('transform failed'); + const group = mutualRecursiveAsync((ref) => ({ + a: objectAsync({ item: ref('b') }), + b: pipeAsync( + string(), + transformAsync(async () => { + throw error; + }) + ), + })); + await expect(parseAsync(group.a, { item: 'value' })).rejects.toBe(error); + }); + + test('propagates definition exceptions during construction', () => { + const error = new Error('construction failed'); + expect(() => + mutualRecursiveAsync(() => { + throw error; + }) + ).toThrow(error); + }); + + test('composes independent single, mutual, sync, and async owners', async () => { + const single = recursiveAsync((self) => + objectAsync({ children: arrayAsync(self) }) + ); + const sync = mutualRecursive(() => ({ leaf: string() })); + const inner = mutualRecursiveAsync((ref) => ({ + tag: objectAsync({ next: optionalAsync(ref('tag')) }), + })); + const outer = mutualRecursiveAsync((ref) => ({ + a: objectAsync({ + single, + leaf: sync.leaf, + tags: arrayAsync(inner.tag), + next: optionalAsync(ref('a')), + }), + })); + const value = { + single: { children: [{ children: [] }] }, + leaf: 'end', + tags: [{ next: {} }], + }; + await expect( + parseAsync(outer.a, { ...value, next: value }) + ).resolves.toStrictEqual({ ...value, next: value }); + }); +}); diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts new file mode 100644 index 000000000..7f93761dd --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts @@ -0,0 +1,570 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import { readonly, transformAsync as transform } from '../../actions/index.ts'; +import { pipeAsync as pipe } from '../../methods/index.ts'; +import type { + BaseIssue, + BaseSchemaAsync, + GenericSchemaAsync, + InferInput, + InferIssue, + InferOutput, +} from '../../types/index.ts'; +import { arrayAsync as array, type ArrayIssue } from '../array/index.ts'; +import { instance } from '../instance/index.ts'; +import { intersectAsync as intersect } from '../intersect/index.ts'; +import { literal } from '../literal/index.ts'; +import { mapAsync as map } from '../map/index.ts'; +import { nullableAsync as nullable } from '../nullable/index.ts'; +import { number, type NumberIssue } from '../number/index.ts'; +import { objectAsync as object, type ObjectIssue } from '../object/index.ts'; +import { optionalAsync as optional } from '../optional/index.ts'; +import { recordAsync as record } from '../record/index.ts'; +import { setAsync as set } from '../set/index.ts'; +import { string, type StringIssue } from '../string/index.ts'; +import { tupleAsync as tuple } from '../tuple/index.ts'; +import { tupleWithRestAsync as tupleWithRest } from '../tupleWithRest/index.ts'; +import { unionAsync as union, type UnionIssue } from '../union/index.ts'; +import { variantAsync as variant } from '../variant/index.ts'; +import type { + RecursiveMarker, + RecursiveReferences, + RefFactoryAsync, + ResolveRecursiveInput, + ResolveRecursiveOutput, +} from './mutualRecursive.ts'; +import { + mutualRecursiveAsync as mutualRecursive, + type MutualRecursiveSchemaAsync as MutualRecursiveSchema, +} from './mutualRecursiveAsync.ts'; + +describe('mutualRecursiveAsync', () => { + const definition = (ref: RefFactoryAsync) => ({ + a: object({ name: string(), bs: array(ref('b')) }), + b: object({ score: number(), parent: optional(ref('a')) }), + }); + const schemas = mutualRecursive(definition); + interface A { + name: string; + bs: B[]; + } + interface B { + score: number; + parent?: A | undefined; + } + type Issues = ObjectIssue | ArrayIssue | StringIssue | NumberIssue; + + test('preserves exact definition names', () => { + expectTypeOf().toEqualTypeOf<'a' | 'b'>(); + + // @ts-expect-error Only declared definitions are returned. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- This access checks that an undeclared name is rejected. + schemas.missing; + }); + + test('returns a schema with the exact wrapped definition at each name', () => { + expectTypeOf(schemas.a).toEqualTypeOf< + MutualRecursiveSchema['a'], A, A, Issues> + >(); + expectTypeOf(schemas.b).toEqualTypeOf< + MutualRecursiveSchema['b'], B, B, Issues> + >(); + expectTypeOf(schemas.a.wrapped).toEqualTypeOf< + ReturnType['a'] + >(); + expectTypeOf(schemas.a).toExtend(); + expectTypeOf(schemas.a.async).toEqualTypeOf(); + expectTypeOf(schemas.a.type).toEqualTypeOf<'mutualRecursiveAsync'>(); + }); + + test('infers input and output for every root', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves independent self-recursion and mutual recursion', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ children: array(ref('a')), links: array(ref('b')) }), + b: object({ children: array(ref('b')), parent: optional(ref('a')) }), + })); + interface A { + children: A[]; + links: B[]; + } + interface B { + children: B[]; + parent?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves cycles through three definitions', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ b: optional(ref('b')) }), + b: object({ c: optional(ref('c')) }), + c: object({ a: optional(ref('a')) }), + })); + interface A { + b?: B | undefined; + } + interface B { + c?: C | undefined; + } + interface C { + a?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves transformations and defaults at every level', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ items: array(ref('b')) }), + b: object({ + value: pipe( + string(), + transform(async (value) => Number(value)) + ), + label: optional(string(), 'default'), + parent: optional(ref('a')), + }), + })); + interface InputA { + items: InputB[]; + } + interface InputB { + value: string; + label?: string | undefined; + parent?: InputA | undefined; + } + interface OutputA { + items: OutputB[]; + } + interface OutputB { + value: number; + label: string; + parent?: OutputA | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf<{ items: { value: number }[] }>().not.toExtend< + InferInput + >(); + expectTypeOf<{ items: { value: string; label: string }[] }>().not.toExtend< + InferOutput + >(); + expectTypeOf<{ items: { value: number }[] }>().not.toExtend< + InferOutput + >(); + }); + + test('resolves whole-object transformations across definitions', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ items: array(ref('b')) }), + b: pipe( + object({ parent: optional(ref('a')) }), + transform(async (value) => ({ ...value, label: 'item' as const })) + ), + })); + interface InputA { + items: InputB[]; + } + interface InputB { + parent?: InputA | undefined; + } + interface OutputA { + items: OutputB[]; + } + interface OutputB { + parent?: OutputA | undefined; + label: 'item'; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('preserves recursion when composing a selected root', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const selected = pipe( + schemas.a, + transform(async (value) => value.bs) + ); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferInput>> + >().toEqualTypeOf(); + }); + + test('resolves containers and readonly arrays', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ + records: record(string(), nullable(ref('b'))), + maps: map(ref('b'), ref('b')), + sets: set(ref('b')), + pair: tuple([string(), optional(ref('b'))]), + children: pipe(array(ref('b')), readonly()), + }), + b: object({ parent: optional(ref('a')) }), + })); + interface InputA { + records: Record; + maps: Map; + sets: Set; + pair: [string, InputB | undefined]; + children: InputB[]; + } + interface InputB { + parent?: InputA | undefined; + } + interface OutputA { + records: Record; + maps: Map; + sets: Set; + pair: [string, OutputB | undefined]; + readonly children: readonly OutputB[]; + } + interface OutputB { + parent?: OutputA | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves recursive tuples and variadic tuples', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: union([tuple([string()]), tuple([string(), ref('b')])]), + b: tupleWithRest([number()], ref('a')), + })); + type A = [string] | [string, B]; + type B = [number, ...A[]]; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves unions without an enclosing object', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: union([string(), array(ref('b'))]), + b: union([string(), array(ref('a'))]), + })); + type A = string | B[]; + type B = string | A[]; + type Issues = + | StringIssue + | ArrayIssue + | UnionIssue; + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves variants inside intersections', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + node: intersect([ + object({ id: number() }), + variant('kind', [ + object({ kind: literal('leaf'), value: string() }), + object({ kind: literal('branch'), children: array(ref('node')) }), + ]), + ]), + root: object({ node: ref('node') }), + })); + type Node = { id: number } & ( + | { kind: 'leaf'; value: string } + | { kind: 'branch'; children: Node[] } + ); + expectTypeOf>().toExtend(); + expectTypeOf().toExtend>(); + expectTypeOf>().toExtend<{ node: Node }>(); + expectTypeOf<{ node: Node }>().toExtend>(); + expectTypeOf<{ + id: number; + kind: 'branch'; + children: { id: number; kind: 'leaf'; value: number }[]; + }>().not.toExtend>(); + }); + + test('preserves non-recursive instances and branded container subclasses', () => { + class User { + private readonly _brand!: 'User'; + } + class CustomMap extends Map { + private readonly _brand!: 'CustomMap'; + } + class CustomSet extends Set { + private readonly _brand!: 'CustomSet'; + } + class CustomArray extends Array { + private readonly _brand!: 'CustomArray'; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + a: object({ + user: instance(User), + url: instance(URL), + error: instance(Error), + bytes: instance(Uint8Array), + maps: instance(CustomMap), + sets: instance(CustomSet), + arrays: instance(CustomArray), + next: optional(ref('a')), + }), + })); + interface A { + user: User; + url: URL; + error: Error; + bytes: InstanceType; + maps: CustomMap; + sets: CustomSet; + arrays: CustomArray; + next?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('keeps nested independent definitions independent', () => { + const inner = mutualRecursive((ref) => ({ + tag: object({ aliases: array(ref('tag')) }), + })); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const outer = mutualRecursive((ref) => ({ + node: object({ children: array(ref('node')), tags: array(inner.tag) }), + })); + interface Tag { + aliases: Tag[]; + } + interface Node { + children: Node[]; + tags: Tag[]; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('preserves names that collide with Object prototype properties', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive((ref) => ({ + constructor: object({ next: optional(ref('toString')) }), + toString: object({ next: optional(ref('constructor')) }), + })); + expectTypeOf().toEqualTypeOf< + 'constructor' | 'toString' + >(); + interface A { + next?: B | undefined; + } + interface B { + next?: A | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves promises, readonly containers, and tuple modifiers', () => { + interface Root { + value: number; + } + interface Definitions { + node: Root; + } + type Marker = RecursiveMarker<'node'>; + interface Input { + promise: Promise; + map: ReadonlyMap; + set: ReadonlySet; + tuple: readonly [name: string, node?: Marker]; + array: readonly Marker[]; + long: [ + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + Marker, + ]; + } + interface Expected { + promise: Promise; + map: ReadonlyMap; + set: ReadonlySet; + tuple: readonly [name: string, node?: Root]; + array: readonly Root[]; + long: [Root, Root, Root, Root, Root, Root, Root, Root, Root]; + } + expectTypeOf< + ResolveRecursiveInput + >().toEqualTypeOf(); + expectTypeOf< + ResolveRecursiveOutput + >().toEqualTypeOf(); + }); + + test('collects no references from any, unknown, or never', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('collects concrete references beside any and unknown properties', () => { + expectTypeOf< + RecursiveReferences<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + arbitrary: any; + unknown: unknown; + next: RecursiveMarker<'node'>; + }> + >().toEqualTypeOf>(); + }); + + test('collects references through containers and cyclic objects', () => { + interface Cycle { + next?: Cycle; + reference: RecursiveMarker<'cycle'>; + } + interface Containers { + optional?: RecursiveMarker<'optional'>; + array: RecursiveMarker<'array'>[]; + tuple: readonly [RecursiveMarker<'tuple'>?]; + map: ReadonlyMap, RecursiveMarker<'value'>>; + set: ReadonlySet>; + promise: Promise>; + cycle: Cycle; + } + expectTypeOf>().toEqualTypeOf< + | RecursiveMarker<'optional'> + | RecursiveMarker<'array'> + | RecursiveMarker<'tuple'> + | RecursiveMarker<'key'> + | RecursiveMarker<'value'> + | RecursiveMarker<'set'> + | RecursiveMarker<'promise'> + | RecursiveMarker<'cycle'> + >(); + }); + + test('checks concrete output references independently of unknown input', () => { + const valid = {} as BaseSchemaAsync< + unknown, + RecursiveMarker<'node'>, + BaseIssue + >; + const invalid = {} as BaseSchemaAsync< + unknown, + RecursiveMarker<'missing'>, + BaseIssue + >; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive(() => ({ node: string(), root: valid })); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error Unknown input must not hide a missing output reference. + mutualRecursive(() => ({ node: string(), root: invalid })); + }); + + test('checks concrete input references independently of unknown output', () => { + const valid = {} as BaseSchemaAsync< + RecursiveMarker<'node'>, + unknown, + BaseIssue + >; + const invalid = {} as BaseSchemaAsync< + RecursiveMarker<'missing'>, + unknown, + BaseIssue + >; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The type assertions use this variable. + const group = mutualRecursive(() => ({ node: string(), root: valid })); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error Unknown output must not hide a missing input reference. + mutualRecursive(() => ({ node: string(), root: invalid })); + }); + + test('checks sibling definitions independently of an any-typed definition', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + const arbitrary = {} as any; + mutualRecursive((ref) => ({ + arbitrary, + node: object({ next: optional(ref('node')) }), + })); + // @ts-expect-error An any-typed definition must not hide a missing reference. + mutualRecursive((ref) => ({ + arbitrary, + node: object({ next: ref('missing') }), + })); + }); + + test('rejects references to missing definitions', () => { + // @ts-expect-error The referenced name is not declared. + mutualRecursive((ref) => ({ a: object({ next: ref('missing') }) })); + // @ts-expect-error References in input still count after a transform removes them. + mutualRecursive((ref) => ({ + a: pipe( + object({ bad: ref('missing') }), + transform(async () => 'done') + ), + })); + mutualRecursive((ref) => ({ + a: object({ + // @ts-expect-error Named references require one argument. + next: ref(), + }), + })); + }); + + test('keeps the reference factory separate from reference schemas', () => { + mutualRecursive((ref) => { + expectTypeOf(ref).toEqualTypeOf(); + // @ts-expect-error A reference factory is not a schema. + array(ref); + // @ts-expect-error A reference factory has no schema execution method. + expectTypeOf(ref['~run']); + return { a: object({ next: optional(ref('a')) }) }; + }); + }); + + test('rejects invalid callback results', () => { + // @ts-expect-error A single schema is not a definitions object. + mutualRecursive(() => string()); + // @ts-expect-error Every definition must be a schema. + mutualRecursive(() => ({ a: 123 })); + // @ts-expect-error A definition callback cannot return undefined. + mutualRecursive(() => undefined); + // @ts-expect-error Async definition callbacks are not supported. + mutualRecursive(async () => ({ a: string() })); + mutualRecursive(() => ({ a: object({}), b: string() })); + }); +}); diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts new file mode 100644 index 000000000..4197e88d1 --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, expectTypeOf, test, vi } from 'vitest'; +import { readonly, transformAsync } from '../../actions/index.ts'; +import { parseAsync, pipeAsync, safeParseAsync } from '../../methods/index.ts'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { arrayAsync, type ArrayIssue } from '../array/index.ts'; +import { mapAsync } from '../map/index.ts'; +import { number } from '../number/index.ts'; +import { objectAsync, type ObjectIssue } from '../object/index.ts'; +import { optionalAsync } from '../optional/index.ts'; +import { recordAsync } from '../record/index.ts'; +import { setAsync } from '../set/index.ts'; +import { string, type StringIssue } from '../string/index.ts'; +import { tupleAsync } from '../tuple/index.ts'; +import type { RefFactoryAsync } from './mutualRecursive.ts'; +import { mutualRecursiveAsync as recursiveAsync } from './mutualRecursiveAsync.ts'; + +interface AInput { + value: string; + bs: BInput[]; +} +interface BInput { + parent?: AInput | undefined; +} +interface AOutput { + value: number; + bs: BOutput[]; +} +interface BOutput { + parent?: AOutput | undefined; +} + +const definition = vi.fn((ref: RefFactoryAsync) => ({ + a: objectAsync({ + value: pipeAsync( + string('invalid value'), + transformAsync(async (value) => value.length) + ), + bs: arrayAsync(ref('b')), + }), + b: objectAsync({ parent: optionalAsync(ref('a')) }), +})); +const group = recursiveAsync(definition); + +const input = { value: 'root', bs: [{ parent: { value: 'child', bs: [] } }] }; +const output = { value: 4, bs: [{ parent: { value: 5, bs: [] } }] }; + +describe('mutualRecursiveAsync', () => { + test('infers input, output, and issues for both roots', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + StringIssue | ObjectIssue | ArrayIssue + >(); + }); + + test('constructs once and transforms both roots across recursive levels', async () => { + expect(definition).toHaveBeenCalledTimes(1); + expect(await parseAsync(group.a, input)).toStrictEqual(output); + expect(await parseAsync(group.b, { parent: input })).toStrictEqual({ + parent: output, + }); + expect(definition).toHaveBeenCalledTimes(1); + }); + + test('composes a selected root without changing internal references', async () => { + const selected = pipeAsync( + group.a, + transformAsync(async (value) => value.bs) + ); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expect(await parseAsync(arrayAsync(selected), [input])).toStrictEqual([ + output.bs, + ]); + }); + + test('supports Standard Schema on both roots', async () => { + expect(await group.a['~standard'].validate(input)).toStrictEqual({ + typed: true, + value: output, + }); + expect( + await group.b['~standard'].validate({ parent: input }) + ).toStrictEqual({ typed: true, value: { parent: output } }); + }); + + test('preserves nested issue paths and messages', async () => { + const result = await safeParseAsync(group.a, { + value: 'root', + bs: [{ parent: { value: 123, bs: [] } }], + }); + expect(result.success).toBe(false); + expect(result.issues?.[0].message).toBe('invalid value'); + expect(result.issues?.[0].path?.map(({ key }) => key)).toStrictEqual([ + 'bs', + 0, + 'parent', + 'value', + ]); + }); + + test('resolves a cycle through three definitions', async () => { + const schemas = recursiveAsync((ref) => ({ + a: objectAsync({ b: optionalAsync(ref('b')) }), + b: objectAsync({ c: optionalAsync(ref('c')) }), + c: objectAsync({ a: optionalAsync(ref('a')) }), + })); + const value = { b: { c: { a: {} } } }; + expect(await parseAsync(schemas.a, value)).toStrictEqual(value); + }); + + test('accepts labels that collide with Object prototype names', async () => { + const schemas = recursiveAsync((ref) => ({ + constructor: objectAsync({ next: optionalAsync(ref('toString')) }), + toString: string(), + })); + expect( + await parseAsync(schemas.constructor, { next: 'end' }) + ).toStrictEqual({ next: 'end' }); + }); + + test('rejects unmatched references during construction', () => { + expect(() => { + // @ts-expect-error Deliberately exercise the runtime unmatched-label check. + return recursiveAsync((ref) => ({ + a: objectAsync({ bad: ref('missing') }), + })); + }).toThrowError("Unmatched recursive reference: 'missing'"); + }); + + test('passes a plain factory that creates separate reference schemas', async () => { + const group = recursiveAsync((ref) => { + expect(typeof ref).toBe('function'); + expect(ref).not.toHaveProperty('kind'); + expect(ref).not.toHaveProperty('~run'); + expect(ref).not.toHaveProperty('~standard'); + const next = ref('a'); + expect(typeof next).toBe('object'); + expect(next).toMatchObject({ kind: 'schema', name: 'a', async: true }); + return { a: objectAsync({ next: optionalAsync(next) }) }; + }); + await expect(parseAsync(group.a, { next: {} })).resolves.toStrictEqual({ + next: {}, + }); + }); + + test('resolves named references through containers, tuples, and readonly output', async () => { + const schemas = recursiveAsync((ref) => ({ + a: objectAsync({ + records: recordAsync(string(), ref('b')), + maps: mapAsync(string(), ref('b')), + sets: setAsync(ref('b')), + pair: tupleAsync([number(), optionalAsync(ref('b'))]), + children: pipeAsync(arrayAsync(ref('b')), readonly()), + }), + b: objectAsync({ parent: optionalAsync(ref('a')) }), + })); + interface A { + records: Record; + maps: Map; + sets: Set; + pair: [number, B | undefined]; + children: B[]; + } + interface B { + parent?: A | undefined; + } + interface OutA { + records: Record; + maps: Map; + sets: Set; + pair: [number, OutB | undefined]; + readonly children: readonly OutB[]; + } + interface OutB { + parent?: OutA | undefined; + } + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + const value = { + records: { item: {} }, + maps: new Map([['item', {}]]), + sets: new Set([{}]), + pair: [1, {}], + children: [{}], + }; + expect(await parseAsync(schemas.a, value)).toStrictEqual(value); + }); + + test('keeps independent self-recursive edges on each definition', async () => { + const schemas = recursiveAsync((ref) => ({ + a: objectAsync({ + self: optionalAsync(ref('a')), + other: optionalAsync(ref('b')), + }), + b: objectAsync({ + self: optionalAsync(ref('b')), + other: optionalAsync(ref('a')), + }), + })); + const value = { self: {}, other: { self: {}, other: {} } }; + expect(await parseAsync(schemas.a, value)).toStrictEqual(value); + expect(await parseAsync(schemas.b, value)).toStrictEqual(value); + }); + + test('keeps concurrent parses independent', async () => { + expect( + await Promise.all([ + parseAsync(group.a, input), + parseAsync(group.a, input), + ]) + ).toStrictEqual([output, output]); + }); +}); diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts new file mode 100644 index 000000000..c009fb40a --- /dev/null +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts @@ -0,0 +1,158 @@ +import type { + BaseIssue, + BaseSchemaAsync, + GenericSchema, + GenericSchemaAsync, + InferInput, + InferIssue, + InferOutput, +} from '../../types/index.ts'; +import { _standardSchema } from '../../utils/index.ts'; +import { + checkRecursiveReferences, + type ConfigWithOwner, + createRefFactory, + OwnerSymbol, + type RecursiveMarker, + type RecursiveOwner, + type RecursiveReferences, + type RefFactoryAsync, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, +} from './mutualRecursive.ts'; + +/** + * Mutual recursive schema async interface. + */ +export interface MutualRecursiveSchemaAsync< + TWrapped extends SyncOrAsyncSchema = SyncOrAsyncSchema, + TInput = unknown, + TOutput = unknown, + TIssue extends BaseIssue = BaseIssue, +> extends BaseSchemaAsync { + /** + * The schema type. + */ + readonly type: 'mutualRecursiveAsync'; + /** + * The schema reference. + */ + readonly reference: typeof createRecursiveSchema; + /** + * The expected property. + */ + readonly expects: 'unknown'; + /** + * The wrapped schema. + */ + readonly wrapped: TWrapped; +} + +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; +type Entries = Record; + +type DefinitionInputs = { + [TKey in keyof TEntries]: InferInput; +}; +type DefinitionOutputs = { + [TKey in keyof TEntries]: InferOutput; +}; + +interface Error { + readonly reason: TReason; + readonly impossible: never; +} + +type ReferenceNames = + TReference extends RecursiveMarker ? TName : never; + +type UnmatchedReferences = Exclude< + ReferenceNames< + RecursiveReferences< + DefinitionInputs | DefinitionOutputs + > + >, + keyof TEntries +>; +type CheckReferences = [ + UnmatchedReferences, +] extends [never] + ? unknown + : Error<`Unmatched recursive reference: '${Extract, string>}'`>; + +/** + * Resolved mutual recursive schema async interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. +export interface ResolvedRecursiveSchemaAsync< + TWrapped extends SyncOrAsyncSchema, + TInputRoot, + TOutputRoot, + TIssue extends BaseIssue, +> extends MutualRecursiveSchemaAsync< + TWrapped, + ResolveRecursiveInput, TInputRoot>, + ResolveRecursiveOutput, TOutputRoot>, + TIssue + > {} + +type RecursiveSchemas = { + [TName in keyof TEntries]: ResolvedRecursiveSchemaAsync< + TEntries[TName], + DefinitionInputs, + DefinitionOutputs, + InferIssue + >; +}; + +/** + * Creates a map of mutually recursive async schemas. + * + * @param build The callback that builds the schema entries. It runs once during construction. + * + * @returns A map of recursive async schemas. + */ +export function mutualRecursiveAsync( + build: (ref: RefFactoryAsync) => TEntries & CheckReferences +): RecursiveSchemas; +export function mutualRecursiveAsync( + build: (ref: RefFactoryAsync) => Entries +): unknown { + const owner = { + async: true, + reference: createRecursiveSchema, + get definition(): Entries { + return definitions; + }, + } satisfies RecursiveOwner; + const definitions = build(createRefFactory(owner)); + const seen = new Set(); + const entries: Record = + Object.create(null); + for (const key of Object.keys(definitions)) { + checkRecursiveReferences(definitions[key], owner, seen); + entries[key] = createRecursiveSchema(definitions[key], owner); + } + return entries; +} + +function createRecursiveSchema( + root: SyncOrAsyncSchema, + owner: RecursiveOwner +): MutualRecursiveSchemaAsync { + return _standardSchema({ + kind: 'schema', + type: 'mutualRecursiveAsync', + reference: createRecursiveSchema, + expects: 'unknown', + async: true, + wrapped: root, + async '~run'(dataset, config) { + const executionConfig: ConfigWithOwner = { + ...config, + [OwnerSymbol]: owner, + }; + return root['~run'](dataset, executionConfig); + }, + }); +} diff --git a/library/src/schemas/recursive/index.ts b/library/src/schemas/recursive/index.ts index 61cd59506..81fff683d 100644 --- a/library/src/schemas/recursive/index.ts +++ b/library/src/schemas/recursive/index.ts @@ -1,15 +1,18 @@ export { recursive, + type RecursiveSchema, + type ResolvedRecursiveSchema, +} from './recursive.ts'; +export { type RecursiveMarkerSymbol, - type GenericRecursiveSchema, type RecursiveArray, type RecursiveMarker, type RecursiveReadonlyArray, - type RecursiveSchema, - type RecursiveSelfSchema, + type Ref, + type RefAsync, } from './recursive.ts'; export { recursiveAsync, type RecursiveSchemaAsync, - type RecursiveSelfSchemaAsync, + type ResolvedRecursiveSchemaAsync, } from './recursiveAsync.ts'; diff --git a/library/src/schemas/recursive/recursive.compiler.test.ts b/library/src/schemas/recursive/recursive.compiler.test.ts new file mode 100644 index 000000000..3272fe8b8 --- /dev/null +++ b/library/src/schemas/recursive/recursive.compiler.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import * as ts from 'typescript'; +import { describe, expect, test } from 'vitest'; + +const fixture = ts.sys.resolvePath( + 'src/schemas/recursive/recursive-optional.fixture.ts' +); + +/** + * Compiles optional recursive schemas with the selected compiler options. + * + * @param async Whether to compile an async schema. + * @param exactOptionalPropertyTypes Whether to use exact optional properties. + * + * @returns The compiler errors. + */ +function compile(async: boolean, exactOptionalPropertyTypes: boolean) { + const imports = async + ? 'recursiveAsync as recursive, objectAsync as object, optionalAsync as optional, string' + : 'recursive, object, optional, string'; + const source = `import { expectTypeOf } from 'vitest'; +import { ${imports}, type InferInput, type InferOutput } from '../../index.ts'; + +interface Node { + name: string; + next?: Node | undefined; +} + +const schema = recursive((self) => object({ + name: string(), + next: optional(self), +})); + +expectTypeOf>().toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); + +const input: InferInput = { + name: 'root', + next: { name: 'child', next: { name: 'leaf' } }, +}; +const output: InferOutput = { + name: 'root', + next: { name: 'child', next: { name: 'leaf' } }, +}; + +// @ts-expect-error A nested input name must be a string. +const invalidInput: InferInput = { name: 'root', next: { name: 123 } }; +// @ts-expect-error A nested output name must be a string. +const invalidOutput: InferOutput = { name: 'root', next: { name: 123 } }; +`; + const options: ts.CompilerOptions = { + strict: true, + exactOptionalPropertyTypes, + skipLibCheck: true, + noEmit: true, + target: ts.ScriptTarget.ES2020, + lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + allowImportingTsExtensions: true, + }; + const host = ts.createCompilerHost(options); + const readFile = host.readFile; + host.readFile = (path) => (path === fixture ? source : readFile(path)); + const program = ts.createProgram([fixture], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ); +} + +describe.each([false, true])( + 'optional recursion with exactOptionalPropertyTypes: %s', + (exactOptionalPropertyTypes) => { + test.each([false, true])( + 'resolves exact input and output types (async: %s)', + (async) => { + expect(compile(async, exactOptionalPropertyTypes)).toEqual([]); + }, + 60_000 + ); + } +); diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index 28ab269bb..b8a814b19 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -26,22 +26,36 @@ import { tuple } from '../tuple/index.ts'; import { tupleWithRest } from '../tupleWithRest/index.ts'; import { union, type UnionIssue, type UnionSchema } from '../union/index.ts'; import { variant } from '../variant/index.ts'; -import { - type GenericRecursiveSchema, - recursive, - type RecursiveSchema, - type RecursiveSelfSchema, -} from './recursive.ts'; +import { recursive, type RecursiveSchema, type Ref } from './recursive.ts'; describe('recursive', () => { + test('rejects definition maps and callable self references', () => { + // @ts-expect-error Mutual recursion belongs to mutualRecursive. + recursive(() => ({ a: string() })); + recursive((self) => { + // @ts-expect-error A self reference is a schema, not a named-reference factory. + self('a'); + return string(); + }); + }); test('should return schema object', () => { - const getter = (self: RecursiveSelfSchema) => + const definition = (self: Ref) => object({ name: string(), subcategories: array(self), }); - expectTypeOf(recursive(getter)).toEqualTypeOf< - RecursiveSchema> + interface Category { + name: string; + subcategories: Category[]; + } + + expectTypeOf(recursive(definition)).toEqualTypeOf< + RecursiveSchema< + ReturnType, + Category, + Category, + ObjectIssue | ArrayIssue | StringIssue + > >(); }); @@ -173,6 +187,17 @@ describe('recursive', () => { expectTypeOf>().toEqualTypeOf(); }); + test('should infer recursive tuples without an enclosing object', () => { + const List = recursive((self) => + union([tuple([string()]), tuple([string(), self])]) + ); + + type List = [string] | [string, List]; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + test('should infer nested recursive schema types', () => { const Category = recursive((category) => object({ @@ -207,12 +232,12 @@ describe('recursive', () => { test('should infer recursive union issues', () => { type Schema = RecursiveSchema< UnionSchema< - readonly [ - StringSchema, - ArraySchema, - ], + readonly [StringSchema, ArraySchema], undefined - > + >, + Json, + Json, + StringIssue | ArrayIssue | UnionIssue >; type Json = string | Json[]; @@ -264,10 +289,13 @@ describe('recursive', () => { ObjectSchema< { name: StringSchema; - subcategories: ArraySchema; + subcategories: ArraySchema; }, undefined - > + >, + Input, + Input, + ObjectIssue | StringIssue | ArrayIssue >; interface Input { @@ -377,6 +405,6 @@ describe('recursive', () => { }) ); - expectTypeOf(Category).toExtend(); + expectTypeOf(Category).toExtend(); }); }); diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index 0e65f42d8..a19653bb0 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -1,28 +1,31 @@ import { describe, expect, test, vi } from 'vitest'; +import { returns } from '../../actions/index.ts'; +import { parse, pipe } from '../../methods/index.ts'; import type { FailureDataset, InferIssue } from '../../types/index.ts'; import { expectNoSchemaIssue } from '../../vitest/index.ts'; +import { any } from '../any/index.ts'; import { array } from '../array/index.ts'; import { object } from '../object/index.ts'; import { string } from '../string/index.ts'; -import { - recursive, - type RecursiveSchema, - type RecursiveSelfSchema, -} from './recursive.ts'; +import { recursive, type RecursiveSchema, type Ref } from './recursive.ts'; describe('recursive', () => { test('should return schema object', () => { - const getter = (self: RecursiveSelfSchema) => + const definition = vi.fn((self: Ref) => object({ name: string(), subcategories: array(self), - }); - expect(recursive(getter)).toStrictEqual({ + }) + ); + const schema = recursive(definition); + expect(schema.wrapped).toBe(definition.mock.results[0].value); + expect(definition).toHaveBeenCalledTimes(1); + expect(schema).toStrictEqual({ kind: 'schema', type: 'recursive', reference: recursive, expects: 'unknown', - getter, + wrapped: definition.mock.results[0].value, async: false, '~standard': { version: 1, @@ -30,7 +33,7 @@ describe('recursive', () => { validate: expect.any(Function), }, '~run': expect.any(Function), - } satisfies RecursiveSchema>); + } satisfies RecursiveSchema>); }); describe('should return dataset without issues', () => { @@ -55,50 +58,6 @@ describe('recursive', () => { }, ]); }); - - test('for nested recursive schemas', () => { - const schema = recursive((category) => - object({ - name: string(), - subcategories: array(category), - links: array( - recursive((link) => - object({ - name: string(), - target: category, - alternatives: array(link), - }) - ) - ), - }) - ); - const input = { - name: 'Root', - subcategories: [{ name: 'Child', subcategories: [], links: [] }], - links: [ - { - name: 'Primary', - target: { name: 'Target', subcategories: [], links: [] }, - alternatives: [ - { - name: 'Fallback', - target: { - name: 'Fallback target', - subcategories: [], - links: [], - }, - alternatives: [], - }, - ], - }, - ], - }; - - expect(schema['~run']({ value: input }, {})).toStrictEqual({ - typed: true, - value: input, - }); - }); }); test('should return dataset with nested issues', () => { @@ -156,15 +115,51 @@ describe('recursive', () => { } satisfies FailureDataset>); }); - test('should call getter with self', () => { - const getter = vi.fn((self: RecursiveSelfSchema) => { - expect(self.type).toBe('recursive'); + test('rejects direct self execution without preventing recursive parsing', () => { + const definition = vi.fn((self: Ref) => object({ children: array(self) })); + const schema = recursive(definition); + const self = definition.mock.calls[0][0]; + const input = { children: [{ children: [] }] }; + const config = { abortEarly: true }; + const message = 'Recursive reference used outside its recursive schema'; + + expect(() => parse(self, input, config)).toThrowError(message); + expect(() => self['~run']({ value: input }, config)).toThrowError(message); + expect(() => self['~standard'].validate(input)).toThrowError(message); + expect(() => + recursive(() => object({ children: array(self) })) + ).toThrowError(message); + expect(parse(schema, input, config)).toStrictEqual(input); + expect(schema['~standard'].validate(input)).toStrictEqual({ + typed: true, + value: input, + }); + expect(() => parse(self, input, config)).toThrowError(message); + expect(Reflect.ownKeys(config)).toStrictEqual(['abortEarly']); + }); + + test('rejects a foreign reference inside a returns action during construction', () => { + const definition = vi.fn((self: Ref) => object({ children: array(self) })); + recursive(definition); + const self = definition.mock.calls[0][0]; + + expect(() => recursive(() => pipe(any(), returns(self)))).toThrowError( + 'Recursive reference used outside its recursive schema' + ); + }); + + test('should construct once with a schema-compatible self marker', () => { + const definition = vi.fn((self: Ref) => { + expect(typeof self).toBe('object'); + expect(self.type).toBe('ref'); return string(); }); - const schema = recursive(getter); - expect(getter).not.toHaveBeenCalled(); + const schema = recursive(definition); + expect(definition).toHaveBeenCalledTimes(1); + const self = definition.mock.calls[0][0]; + expect(self.kind).toBe('schema'); schema['~run']({ value: 'foo' }, {}); - expect(getter).toHaveBeenCalledTimes(1); - expect(getter).toHaveBeenCalledWith(schema); + schema['~run']({ value: 'bar' }, {}); + expect(definition).toHaveBeenCalledTimes(1); }); }); diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index f33e1d17b..0429b4d4b 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -1,64 +1,37 @@ import type { BaseIssue, BaseSchema, + BaseSchemaAsync, + Config, + GenericSchema, + GenericSchemaAsync, InferInput, InferIssue, InferOutput, - OutputDataset, + IsAny, + UnknownDataset, } from '../../types/index.ts'; import { _standardSchema } from '../../utils/index.ts'; /** - * Recursive marker symbol. - */ -export declare const RecursiveMarkerSymbol: unique symbol; - -/** - * Recursive marker type. + * The recursive owner currently running. + * + * @internal */ -export interface RecursiveMarker { - readonly [RecursiveMarkerSymbol]: true; -} +export const OwnerSymbol: unique symbol = Symbol('recursive owner'); /** - * Recursive self schema interface. + * Configuration that carries the recursive owner. + * + * @internal */ -export interface RecursiveSelfSchema - extends BaseSchema { +export interface ConfigWithOwner extends Config> { /** - * The schema type. + * The authorized recursive owner. */ - readonly type: 'recursive'; + readonly [OwnerSymbol]?: RecursiveOwner; } -// Only resolve object shapes that contain the hidden recursive marker so -// inferred class and object instances stay structurally intact. -type ContainsRecursiveMarker< - TValue, - TSeen = never, -> = TValue extends RecursiveMarker - ? true - : [TValue] extends [TSeen] - ? false - : TValue extends - | Map - | ReadonlyMap - ? ContainsRecursiveMarker - : TValue extends Set | ReadonlySet - ? ContainsRecursiveMarker - : TValue extends Promise - ? ContainsRecursiveMarker - : TValue extends readonly (infer TItem)[] - ? ContainsRecursiveMarker - : TValue extends object - ? { - [TKey in keyof TValue]: ContainsRecursiveMarker< - TValue[TKey], - TSeen | TValue - >; - }[keyof TValue] - : false; - // Named interfaces keep recursive array output from collapsing into circular // type aliases while preserving normal array behavior. /* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ @@ -83,6 +56,92 @@ type ResolveRecursiveTuple = { [TKey in keyof TValue]: ResolveRecursiveValue; }; +// Literal tuple branches defer recursive elements without a spread or mapped +// tuple. Optional, readonly, and longer tuples retain the existing resolver. +type ResolveRecursiveFixedTuple< + TValue extends readonly unknown[], + TRoot, +> = TValue extends [infer A] + ? [ResolveRecursiveValue] + : TValue extends [infer A, infer B] + ? [ResolveRecursiveValue, ResolveRecursiveValue] + : TValue extends [infer A, infer B, infer C] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [infer A, infer B, infer C, infer D] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [infer A, infer B, infer C, infer D, infer E] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + infer G, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + infer G, + infer H, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : ResolveRecursiveTuple; + type ResolveRecursiveArray< TValue extends readonly unknown[], TRoot, @@ -95,15 +154,19 @@ type ResolveRecursiveArray< | readonly [...unknown[], unknown] ? ResolveRecursiveTuple : TValue extends (infer TItem)[] - ? RecursiveMarker extends TItem + ? true extends ( + TItem extends RecursiveMarker ? true : false + ) ? RecursiveArray : ResolveRecursiveValue[] : TValue extends readonly (infer TItem)[] - ? RecursiveMarker extends TItem + ? true extends ( + TItem extends RecursiveMarker ? true : false + ) ? RecursiveReadonlyArray : readonly ResolveRecursiveValue[] : never - : ResolveRecursiveTuple; + : ResolveRecursiveFixedTuple; // Rebuilds a container with its type arguments resolved. TypeScript cannot // reconstruct an arbitrary generic with substituted type arguments, so each @@ -139,11 +202,30 @@ type ResolveRecursiveContainer = } : TValue; -type ResolveRecursiveValue = TValue extends RecursiveMarker - ? ResolveRecursiveValue - : true extends ContainsRecursiveMarker - ? ResolveRecursiveContainer - : TValue; +type MergeReference = { + [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; +}; + +type ResolveRecursiveValue = + IsAny extends true + ? TValue + : TValue extends RecursiveMarker + ? TKey extends undefined + ? ResolveRecursiveValue + : TKey extends keyof TRoot + ? Exclude extends never + ? ResolveRecursiveValue + : MergeReference< + ResolveRecursiveValue, + ResolveRecursiveContainer< + Omit, + TRoot + > + > + : never + : [RecursiveReferences] extends [never] + ? TValue + : ResolveRecursiveContainer; /** * Resolve recursive input type. @@ -166,80 +248,313 @@ export type ResolveRecursiveOutput< > = ResolveRecursiveValue; /** - * Generic recursive schema interface. + * Recursive schema interface. */ -export interface GenericRecursiveSchema - extends BaseSchema> { +export interface RecursiveSchema< + TWrapped extends GenericSchema = GenericSchema, + TInput = unknown, + TOutput = unknown, + TIssue extends BaseIssue = BaseIssue, +> extends BaseSchema { /** * The schema type. */ readonly type: 'recursive'; + /** + * The schema reference. + */ + readonly reference: typeof recursive; /** * The expected property. */ readonly expects: 'unknown'; /** - * The schema getter. + * The wrapped schema. */ - readonly getter: ( - self: RecursiveSelfSchema - ) => BaseSchema>; + readonly wrapped: TWrapped; } +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; + /** - * Recursive schema interface. + * Common runtime contract for recursive reference owners. + * + * @internal */ -export interface RecursiveSchema< - TWrapped extends BaseSchema>, -> extends BaseSchema< - ResolveRecursiveInput>, - ResolveRecursiveOutput>, - InferIssue - > { +export interface RecursiveOwner { /** - * The schema type. - */ - readonly type: 'recursive'; - /** - * The schema reference. + * The callback result that references resolve against. */ - readonly reference: typeof recursive; + readonly definition: SyncOrAsyncSchema | Record; /** - * The expected property. + * Whether references execute asynchronously. */ - readonly expects: 'unknown'; + readonly async: boolean; /** - * The schema getter. + * The owner schema factory. */ - readonly getter: (self: RecursiveSelfSchema) => TWrapped; + readonly reference: SyncOrAsyncSchema['reference']; } +/** + * Collects recursive references from a value. + */ +export type RecursiveReferences = + IsAny extends true + ? never + : TValue extends RecursiveMarker + ? TValue + : [TValue] extends [TSeen] + ? never + : TValue extends + | Map + | ReadonlyMap + ? RecursiveReferences + : TValue extends + | Set + | ReadonlySet + | Promise + ? RecursiveReferences + : TValue extends readonly (infer TItem)[] + ? RecursiveReferences + : TValue extends object + ? { + [TKey in keyof TValue]-?: RecursiveReferences< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] + : never; + +/** + * Resolved recursive schema interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. +export interface ResolvedRecursiveSchema< + TWrapped extends GenericSchema, + TInputRoot = InferInput, + TOutputRoot = InferOutput, + TIssue extends BaseIssue = InferIssue, +> extends RecursiveSchema< + TWrapped, + ResolveRecursiveInput, TInputRoot>, + ResolveRecursiveOutput, TOutputRoot>, + TIssue + > {} + /** * Creates a recursive schema. * - * @param getter The schema getter. + * @param definition The schema definition. It runs once during construction. * * @returns A recursive schema. */ -// @__NO_SIDE_EFFECTS__ -export function recursive< - const TWrapped extends BaseSchema>, ->(getter: (self: RecursiveSelfSchema) => TWrapped): RecursiveSchema { - return _standardSchema({ +export function recursive( + definition: (self: Ref) => TWrapped +): ResolvedRecursiveSchema; +export function recursive(definition: (self: Ref) => GenericSchema): unknown { + const owner = { + async: false, + reference: recursive, + get definition(): GenericSchema { + return wrapped; + }, + } satisfies RecursiveOwner; + const wrapped = definition(createRef(owner)); + checkRecursiveReferences(wrapped, owner, new Set()); + return _standardSchema({ kind: 'schema', type: 'recursive', - reference: recursive, + reference: recursive, expects: 'unknown', async: false, - getter, + wrapped, '~run'(dataset, config) { - return this.getter(this as unknown as RecursiveSelfSchema)['~run']( - dataset, - config - ) as OutputDataset< - ResolveRecursiveOutput>, - InferIssue - >; + const executionConfig: ConfigWithOwner = { + ...config, + [OwnerSymbol]: owner, + }; + return wrapped['~run'](dataset, executionConfig); }, }); } + +/** + * Rejects references with a foreign owner or an unmatched definition name. + * + * @param value The schema. + * @param owner The owner of the refs. + * @param seen The schemas already visited. + */ +export function checkRecursiveReferences( + value: unknown, + owner: Pick, + seen: Set = new Set() +): void { + if ( + !value || + (typeof value !== 'object' && typeof value !== 'function') || + seen.has(value) + ) { + return; + } + seen.add(value); + const schema = value as Record; + if (schema.kind === 'schema' || schema.kind === 'transformation') { + if (schema.owner !== undefined && schema.owner !== owner) { + throw Error('Recursive reference used outside its recursive schema'); + } + if (schema.owner === owner) { + const name = schema.name as string | undefined; + if (name === undefined) { + if (owner.definition.kind !== 'schema') { + throw Error( + 'Recursive reference in a definitions object must be named' + ); + } + } else if ( + owner.definition.kind === 'schema' || + !Object.hasOwn(owner.definition, name) + ) { + throw Error(`Unmatched recursive reference: '${name}'`); + } + } + } + for (const key in schema) { + if ( + key !== 'owner' && + key !== 'reference' && + key !== '~standard' && + key !== '~types' && + !( + key === 'wrapped' && + [ + 'recursive', + 'recursiveAsync', + 'mutualRecursive', + 'mutualRecursiveAsync', + ].includes(schema.type as string) + ) + ) { + checkRecursiveReferences(schema[key], owner, seen); + } + } +} + +/** + * Recursive marker symbol. + */ +export declare const RecursiveMarkerSymbol: unique symbol; + +/** + * Recursive marker type. + */ +export interface RecursiveMarker { + readonly [RecursiveMarkerSymbol]: TKey; +} + +/** + * Recursive reference interface. + */ +export interface Ref + extends BaseSchema, RecursiveMarker, never> { + /** + * The schema type. + */ + readonly type: 'ref'; + /** + * The recursive schema that owns the reference. + */ + readonly owner: RecursiveOwner; + /** + * The referenced definition name. + */ + readonly name: TName; +} + +/** + * Async recursive reference interface. + */ +export interface RefAsync + extends BaseSchemaAsync< + RecursiveMarker, + RecursiveMarker, + never + > { + /** + * The schema type. + */ + readonly type: 'refAsync'; + /** + * The recursive schema that owns the reference. + */ + readonly owner: RecursiveOwner; + /** + * The referenced definition name. + */ + readonly name: TName; +} + +/** + * Creates a reference to a recursive owner's schema. + * + * @param owner The owner of the reference. + * @param name The definition name, for mutual recursion. + * + * @returns A recursive reference. + * + * @internal + */ +export function createRef( + owner: RecursiveOwner & { readonly async: false }, + name?: TName +): Ref; +export function createRef( + owner: RecursiveOwner & { readonly async: true }, + name?: TName +): RefAsync; +export function createRef( + owner: RecursiveOwner, + name?: TName +): Ref | RefAsync; +export function createRef( + owner: RecursiveOwner, + name?: TName +): Ref | RefAsync { + function run(dataset: UnknownDataset, config: ConfigWithOwner) { + if (config[OwnerSymbol] !== owner) { + throw Error('Recursive reference used outside its recursive schema'); + } + if ( + name !== undefined && + (owner.definition.kind === 'schema' || + !Object.hasOwn(owner.definition, name)) + ) { + throw Error(`Unmatched recursive reference: '${name}'`); + } + const target = + name === undefined + ? (owner.definition as SyncOrAsyncSchema) + : (owner.definition as Record)[name]; + return target['~run'](dataset, config); + } + + const reference = { + kind: 'schema' as const, + type: owner.async ? ('refAsync' as const) : ('ref' as const), + expects: 'unknown', + owner, + async: owner.async, + reference: owner.reference, + name, + '~run': owner.async + ? async (dataset: UnknownDataset, config: ConfigWithOwner) => + run(dataset, config) + : run, + }; + // The marker describes a type-level reference, not the delegated runtime value. + // The recursive resolver replaces it with the target schema's input and output. + return _standardSchema | RefAsync>( + reference as Omit | RefAsync, '~standard'> + ); +} diff --git a/library/src/schemas/recursive/recursiveAsync.execution.test.ts b/library/src/schemas/recursive/recursiveAsync.execution.test.ts new file mode 100644 index 000000000..c93a23009 --- /dev/null +++ b/library/src/schemas/recursive/recursiveAsync.execution.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + checkAsync, + returnsAsync, + transformAsync, +} from '../../actions/index.ts'; +import { parseAsync, pipeAsync, safeParseAsync } from '../../methods/index.ts'; +import { any } from '../any/index.ts'; +import { arrayAsync } from '../array/index.ts'; +import { objectAsync } from '../object/index.ts'; +import { optional } from '../optional/index.ts'; +import { string } from '../string/index.ts'; +import type { RefAsync } from './recursive.ts'; +import { recursiveAsync } from './recursiveAsync.ts'; + +describe('recursiveAsync execution', () => { + test('constructs the definition once before concurrent async parses', async () => { + const definition = vi.fn((self: RefAsync) => + objectAsync({ children: arrayAsync(self) }) + ); + const schema = recursiveAsync(definition); + const input = { children: [{ children: [] }] }; + const parses = [parseAsync(schema, input), parseAsync(schema, input)]; + await expect(Promise.all(parses)).resolves.toStrictEqual([input, input]); + expect(definition).toHaveBeenCalledOnce(); + expect(schema.wrapped).toBe(definition.mock.results[0].value); + }); + + test('propagates a synchronous definition exception during construction', () => { + const error = new Error('construction failed'); + expect(() => + recursiveAsync(() => { + throw error; + }) + ).toThrow(error); + }); + + test('checks foreign references during construction', () => { + const definition = vi.fn((self: RefAsync) => + objectAsync({ children: arrayAsync(self) }) + ); + recursiveAsync(definition); + const foreign = definition.mock.calls[0][0]; + expect(() => + recursiveAsync(() => objectAsync({ children: arrayAsync(foreign) })) + ).toThrow('Recursive reference used outside its recursive schema'); + }); + + test('rejects foreign references inside async function return actions', () => { + const definition = vi.fn((self: RefAsync) => + objectAsync({ children: arrayAsync(self) }) + ); + recursiveAsync(definition); + const foreign = definition.mock.calls[0][0]; + expect(() => + recursiveAsync(() => pipeAsync(any(), returnsAsync(foreign))) + ).toThrow('Recursive reference used outside its recursive schema'); + }); + + test('applies async transformations and defaults at every depth', async () => { + const schema = recursiveAsync((self) => + objectAsync({ + value: pipeAsync( + string(), + transformAsync(async (value) => value.length) + ), + label: optional(string(), 'default'), + children: arrayAsync(self), + }) + ); + const input = { + value: 'root', + children: [{ value: 'child', children: [] }], + }; + const output = { + value: 4, + label: 'default', + children: [{ value: 5, label: 'default', children: [] }], + }; + await expect(parseAsync(schema, input)).resolves.toStrictEqual(output); + await expect(schema['~standard'].validate(input)).resolves.toStrictEqual({ + typed: true, + value: output, + }); + expect(input.children[0].value).toBe('child'); + }); + + test('preserves async validation messages, paths, and abort configuration', async () => { + const schema = recursiveAsync((self) => + objectAsync({ + value: pipeAsync( + string(), + checkAsync(async (value) => value !== 'bad', 'invalid node') + ), + children: arrayAsync(self), + }) + ); + const input = { + value: 'ok', + children: [ + { value: 'bad', children: [] }, + { value: 'bad', children: [] }, + ], + }; + const result = await safeParseAsync(schema, input, { abortEarly: true }); + expect(result.success).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues?.[0]).toMatchObject({ + message: 'invalid node', + abortEarly: true, + }); + expect(result.issues?.[0].path?.map(({ key }) => key)).toStrictEqual([ + 'children', + 0, + 'value', + ]); + const standard = await schema['~standard'].validate(input); + expect(standard.issues).toHaveLength(2); + }); + + test('propagates exceptions from async transformations', async () => { + const error = new Error('transform failed'); + const schema = recursiveAsync(() => + pipeAsync( + string(), + transformAsync(async () => { + throw error; + }) + ) + ); + await expect(parseAsync(schema, 'value')).rejects.toBe(error); + }); + + test('composes independent async recursive owners', async () => { + const inner = recursiveAsync((self) => + objectAsync({ children: arrayAsync(self) }) + ); + const outer = recursiveAsync((self) => + objectAsync({ children: arrayAsync(self), tags: arrayAsync(inner) }) + ); + const input = { + children: [{ children: [], tags: [{ children: [] }] }], + tags: [{ children: [{ children: [] }] }], + }; + await expect(parseAsync(outer, input)).resolves.toStrictEqual(input); + }); +}); diff --git a/library/src/schemas/recursive/recursiveAsync.test-d.ts b/library/src/schemas/recursive/recursiveAsync.test-d.ts index 6721fb752..fe5c6a64d 100644 --- a/library/src/schemas/recursive/recursiveAsync.test-d.ts +++ b/library/src/schemas/recursive/recursiveAsync.test-d.ts @@ -29,21 +29,51 @@ import { import { tupleAsync } from '../tuple/index.ts'; import type { UnionIssue, UnionSchemaAsync } from '../union/index.ts'; import { variantAsync } from '../variant/index.ts'; -import { - recursiveAsync, - type RecursiveSchemaAsync, - type RecursiveSelfSchemaAsync, -} from './recursiveAsync.ts'; +import type { RefAsync } from './recursive.ts'; +import { recursiveAsync, type RecursiveSchemaAsync } from './recursiveAsync.ts'; describe('recursiveAsync', () => { + test('rejects maps, invalid definitions, and named references', () => { + // @ts-expect-error Mutual recursion belongs to mutualRecursiveAsync. + recursiveAsync(() => ({ a: string() })); + // @ts-expect-error A definition must return a schema, not a promise. + recursiveAsync(async () => string()); + // @ts-expect-error A definition must return a schema. + recursiveAsync(() => undefined); + recursiveAsync((self) => { + // @ts-expect-error A self reference is not callable. + self('a'); + return string(); + }); + }); + + test('accepts synchronous schemas and preserves literal outputs', () => { + const schema = recursiveAsync(() => literal('leaf')); + expectTypeOf(schema.async).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'leaf'>(); + expectTypeOf>().toEqualTypeOf<'leaf'>(); + expectTypeOf(schema.wrapped).toEqualTypeOf< + ReturnType> + >(); + }); test('should return schema object', () => { - const getter = (self: RecursiveSelfSchemaAsync) => + const definition = (self: RefAsync) => objectAsync({ name: string(), subcategories: arrayAsync(self), }); - expectTypeOf(recursiveAsync(getter)).toEqualTypeOf< - RecursiveSchemaAsync> + interface Category { + name: string; + subcategories: Category[]; + } + + expectTypeOf(recursiveAsync(definition)).toEqualTypeOf< + RecursiveSchemaAsync< + ReturnType, + Category, + Category, + ObjectIssue | ArrayIssue | StringIssue + > >(); }); @@ -143,10 +173,13 @@ describe('recursiveAsync', () => { UnionSchemaAsync< readonly [ StringSchema, - ArraySchemaAsync, + ArraySchemaAsync, ], undefined - > + >, + Json, + Json, + StringIssue | ArrayIssue | UnionIssue >; type Json = string | Json[]; @@ -159,7 +192,7 @@ describe('recursiveAsync', () => { }); test('should resolve async transforms and defaults at every recursive level', () => { - const Schema = recursiveAsync(async (self) => + const Schema = recursiveAsync((self) => objectAsync({ value: pipeAsync( string(), @@ -253,10 +286,13 @@ describe('recursiveAsync', () => { ObjectSchemaAsync< { name: StringSchema; - subcategories: ArraySchemaAsync; + subcategories: ArraySchemaAsync; }, undefined - > + >, + Input, + Input, + ObjectIssue | StringIssue | ArrayIssue >; interface Input { diff --git a/library/src/schemas/recursive/recursiveAsync.test.ts b/library/src/schemas/recursive/recursiveAsync.test.ts index 351c3a490..7670b2795 100644 --- a/library/src/schemas/recursive/recursiveAsync.test.ts +++ b/library/src/schemas/recursive/recursiveAsync.test.ts @@ -1,28 +1,31 @@ import { describe, expect, test, vi } from 'vitest'; +import { parseAsync } from '../../methods/index.ts'; import type { FailureDataset, InferIssue } from '../../types/index.ts'; import { expectNoSchemaIssueAsync } from '../../vitest/index.ts'; -import { arrayAsync } from '../array/index.ts'; -import { objectAsync } from '../object/index.ts'; +import { array, arrayAsync } from '../array/index.ts'; +import { object, objectAsync } from '../object/index.ts'; import { string } from '../string/index.ts'; -import { - recursiveAsync, - type RecursiveSchemaAsync, - type RecursiveSelfSchemaAsync, -} from './recursiveAsync.ts'; +import type { RefAsync } from './recursive.ts'; +import { recursive } from './recursive.ts'; +import { recursiveAsync, type RecursiveSchemaAsync } from './recursiveAsync.ts'; describe('recursiveAsync', () => { test('should return schema object', () => { - const getter = (self: RecursiveSelfSchemaAsync) => + const definition = vi.fn((self: RefAsync) => objectAsync({ name: string(), subcategories: arrayAsync(self), - }); - expect(recursiveAsync(getter)).toStrictEqual({ + }) + ); + const schema = recursiveAsync(definition); + expect(schema.wrapped).toBe(definition.mock.results[0].value); + expect(definition).toHaveBeenCalledTimes(1); + expect(schema).toStrictEqual({ kind: 'schema', - type: 'recursive', + type: 'recursiveAsync', reference: recursiveAsync, expects: 'unknown', - getter, + wrapped: definition.mock.results[0].value, async: true, '~standard': { version: 1, @@ -30,7 +33,7 @@ describe('recursiveAsync', () => { validate: expect.any(Function), }, '~run': expect.any(Function), - } satisfies RecursiveSchemaAsync>); + } satisfies RecursiveSchemaAsync>); }); describe('should return dataset without issues', () => { @@ -112,20 +115,68 @@ describe('recursiveAsync', () => { } satisfies FailureDataset>); }); - test('should call getter with self', async () => { - const getter = vi.fn((self: RecursiveSelfSchemaAsync) => { - expect(self.type).toBe('recursive'); + test('rejects direct self execution without preventing async recursive parsing', async () => { + const definition = vi.fn((self: RefAsync) => + objectAsync({ children: arrayAsync(self) }) + ); + const schema = recursiveAsync(definition); + const self = definition.mock.calls[0][0]; + const input = { children: [{ children: [] }] }; + const config = { abortEarly: true }; + const message = 'Recursive reference used outside its recursive schema'; + + await expect(parseAsync(self, input, config)).rejects.toThrowError(message); + await expect(self['~run']({ value: input }, config)).rejects.toThrowError( + message + ); + await expect(self['~standard'].validate(input)).rejects.toThrowError( + message + ); + expect(() => + recursiveAsync(() => objectAsync({ children: arrayAsync(self) })) + ).toThrowError(message); + await expect( + Promise.all([ + parseAsync(schema, input, config), + parseAsync(schema, input, config), + ]) + ).resolves.toStrictEqual([input, input]); + await expect(schema['~standard'].validate(input)).resolves.toStrictEqual({ + typed: true, + value: input, + }); + await expect(parseAsync(self, input, config)).rejects.toThrowError(message); + expect(Reflect.ownKeys(config)).toStrictEqual(['abortEarly']); + }); + + test('supports a self-contained sync recursive schema inside an async root', async () => { + const nested = recursive((self) => object({ children: array(self) })); + const schema = recursiveAsync(() => objectAsync({ nested })); + const input = { + nested: { children: [{ children: [{ children: [] }] }] }, + }; + await expect(parseAsync(schema, input)).resolves.toStrictEqual(input); + }); + + test('should call definition with self', async () => { + const definition = vi.fn((self: RefAsync) => { + expect(typeof self).toBe('object'); + expect(self.type).toBe('refAsync'); return string(); }); - const schema = recursiveAsync(getter); - expect(getter).not.toHaveBeenCalled(); + const schema = recursiveAsync(definition); + expect(definition).toHaveBeenCalledTimes(1); await schema['~run']({ value: 'foo' }, {}); - expect(getter).toHaveBeenCalledTimes(1); - expect(getter).toHaveBeenCalledWith(schema); + expect(definition).toHaveBeenCalledTimes(1); + expect(definition.mock.calls[0][0].kind).toBe('schema'); }); - test('should support async getters', async () => { - const schema = recursiveAsync(async () => string()); + test('exposes the wrapped schema immediately', async () => { + const wrapped = string(); + const definition = vi.fn(() => wrapped); + const schema = recursiveAsync(definition); + expect(schema.wrapped).toBe(wrapped); await expectNoSchemaIssueAsync(schema, ['foo']); + expect(definition).toHaveBeenCalledTimes(1); }); }); diff --git a/library/src/schemas/recursive/recursiveAsync.ts b/library/src/schemas/recursive/recursiveAsync.ts index 214673211..5318d6cb5 100644 --- a/library/src/schemas/recursive/recursiveAsync.ts +++ b/library/src/schemas/recursive/recursiveAsync.ts @@ -1,92 +1,103 @@ import type { BaseIssue, - BaseSchema, BaseSchemaAsync, + GenericSchema, + GenericSchemaAsync, InferInput, InferIssue, InferOutput, - MaybePromise, - OutputDataset, } from '../../types/index.ts'; import { _standardSchema } from '../../utils/index.ts'; -import type { - recursive, - RecursiveMarker, - ResolveRecursiveInput, - ResolveRecursiveOutput, +import { + checkRecursiveReferences, + type ConfigWithOwner, + createRef, + OwnerSymbol, + type RecursiveOwner, + type RefAsync, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, } from './recursive.ts'; -/** - * Recursive self schema async interface. - */ -export interface RecursiveSelfSchemaAsync - extends BaseSchemaAsync { - /** - * The schema type. - */ - readonly type: 'recursive'; -} - /** * Recursive schema async interface. */ export interface RecursiveSchemaAsync< - TWrapped extends - | BaseSchema> - | BaseSchemaAsync>, -> extends BaseSchemaAsync< - ResolveRecursiveInput>, - ResolveRecursiveOutput>, - InferIssue - > { + TWrapped extends SyncOrAsyncSchema = SyncOrAsyncSchema, + TInput = unknown, + TOutput = unknown, + TIssue extends BaseIssue = BaseIssue, +> extends BaseSchemaAsync { /** * The schema type. */ - readonly type: 'recursive'; + readonly type: 'recursiveAsync'; /** * The schema reference. */ - readonly reference: typeof recursive | typeof recursiveAsync; + readonly reference: typeof recursiveAsync; /** * The expected property. */ readonly expects: 'unknown'; /** - * The schema getter. + * The wrapped schema. */ - readonly getter: (self: RecursiveSelfSchemaAsync) => MaybePromise; + readonly wrapped: TWrapped; } +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; +/** + * Resolved recursive schema async interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. +export interface ResolvedRecursiveSchemaAsync< + TWrapped extends SyncOrAsyncSchema, + TInputRoot = InferInput, + TOutputRoot = InferOutput, + TIssue extends BaseIssue = InferIssue, +> extends RecursiveSchemaAsync< + TWrapped, + ResolveRecursiveInput, TInputRoot>, + ResolveRecursiveOutput, TOutputRoot>, + TIssue + > {} + /** * Creates a recursive schema. * - * @param getter The schema getter. + * @param definition The schema definition. It runs once during construction. * * @returns A recursive schema. */ -// @__NO_SIDE_EFFECTS__ -export function recursiveAsync< - const TWrapped extends - | BaseSchema> - | BaseSchemaAsync>, ->( - getter: (self: RecursiveSelfSchemaAsync) => MaybePromise -): RecursiveSchemaAsync { - return _standardSchema({ +export function recursiveAsync( + definition: (self: RefAsync) => TWrapped +): ResolvedRecursiveSchemaAsync; +export function recursiveAsync( + definition: (self: RefAsync) => SyncOrAsyncSchema +): unknown { + const owner = { + async: true, + reference: recursiveAsync, + get definition() { + return wrapped; + }, + } satisfies RecursiveOwner; + const wrapped = definition(createRef(owner)); + checkRecursiveReferences(wrapped, owner); + return _standardSchema({ kind: 'schema', - type: 'recursive', - reference: recursiveAsync, + type: 'recursiveAsync', + reference: recursiveAsync, expects: 'unknown', async: true, - getter, + wrapped, async '~run'(dataset, config) { - const outputDataset = await ( - await this.getter(this as unknown as RecursiveSelfSchemaAsync) - )['~run'](dataset, config); - return outputDataset as OutputDataset< - ResolveRecursiveOutput>, - InferIssue - >; + const configWithOwner: ConfigWithOwner = { + ...config, + [OwnerSymbol]: owner, + }; + return wrapped['~run'](dataset, configWithOwner); }, }); } diff --git a/library/src/types/issue.ts b/library/src/types/issue.ts index e307e5814..d400d048f 100644 --- a/library/src/types/issue.ts +++ b/library/src/types/issue.ts @@ -19,6 +19,8 @@ import type { MapIssue, MapSchema, MapSchemaAsync, + MutualRecursiveSchema, + MutualRecursiveSchemaAsync, NonNullableIssue, NonNullableSchema, NonNullableSchemaAsync, @@ -69,8 +71,7 @@ import type { VariantSchema, VariantSchemaAsync, } from '../schemas/index.ts'; -import type { RecursiveSelfSchema } from '../schemas/recursive/recursive.ts'; -import type { RecursiveSelfSchemaAsync } from '../schemas/recursive/recursiveAsync.ts'; +import type { Ref, RefAsync } from '../schemas/recursive/recursive.ts'; import type { Config } from './config.ts'; import type { InferInput } from './infer.ts'; import type { ObjectEntries, ObjectEntriesAsync } from './object.ts'; @@ -516,6 +517,9 @@ export type IssueDotPath< > | LazySchema | RecursiveSchema + | MutualRecursiveSchema< + infer TWrapped + > | NonNullableSchema< infer TWrapped, | ErrorMessage @@ -595,6 +599,9 @@ export type IssueDotPath< > > | LazySchemaAsync + | MutualRecursiveSchemaAsync< + infer TWrapped + > | RecursiveSchemaAsync< infer TWrapped > @@ -680,8 +687,8 @@ export type IssueDotPath< ? IssueDotPath : // Recursive self TSchema extends - | RecursiveSelfSchema - | RecursiveSelfSchemaAsync + | Ref + | RefAsync ? string : // Otherwise never; diff --git a/library/src/utils/getDotPath/getDotPath.test-d.ts b/library/src/utils/getDotPath/getDotPath.test-d.ts index 9373251f4..416df11fd 100644 --- a/library/src/utils/getDotPath/getDotPath.test-d.ts +++ b/library/src/utils/getDotPath/getDotPath.test-d.ts @@ -10,7 +10,7 @@ import type { StringIssue, StringSchema, } from '../../schemas/index.ts'; -import type { RecursiveSelfSchemaAsync } from '../../schemas/recursive/recursiveAsync.ts'; +import type { RefAsync } from '../../schemas/recursive/recursive.ts'; import type { ArrayPathItem, ObjectPathItem } from '../../types/index.ts'; import { getDotPath } from './getDotPath.ts'; @@ -110,7 +110,7 @@ describe('getDotPath', () => { ObjectSchemaAsync< { name: StringSchema; - subcategories: ArraySchemaAsync; + subcategories: ArraySchemaAsync; }, undefined > diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts index 28aea52fc..7cd9f0dd4 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts @@ -1726,14 +1726,17 @@ describe('convertSchema', () => { }); test('should convert recursive schema', () => { - const schema = v.recursive((self) => + const definition = vi.fn((self: v.Ref) => v.object({ name: v.string(), subcategories: v.array(self), }) ); + const schema = v.recursive(definition); const context = createContext(); const jsonSchema = convertSchema({}, schema, undefined, context); + expect(definition).toHaveBeenCalledTimes(1); + expect(schema.wrapped).toBe(definition.mock.results[0].value); const referenceId = context.referenceMap.get(schema); expect(jsonSchema).toStrictEqual({ @@ -1906,6 +1909,44 @@ describe('convertSchema', () => { }); }); + describe('mutual recursive schemas', () => { + test('should convert a cycle between named definitions', () => { + const schemas = v.mutualRecursive((ref) => ({ + parent: v.object({ children: v.array(ref('child')) }), + child: v.object({ parent: ref('parent') }), + })); + const context = createContext(); + const jsonSchema = convertSchema({}, schemas.parent, undefined, context); + const parentId = context.referenceMap.get(schemas.parent); + const childId = context.referenceMap.get(schemas.child.wrapped); + + expect(parentId).toBeDefined(); + expect(childId).toBeDefined(); + expect(childId).not.toBe(parentId); + expect(jsonSchema).toStrictEqual({ $ref: `#/$defs/${parentId}` }); + expect(context.definitions).toStrictEqual({ + [parentId!]: { + type: 'object', + properties: { + children: { + type: 'array', + items: { $ref: `#/$defs/${childId}` }, + }, + }, + required: ['children'], + }, + [childId!]: { + type: 'object', + properties: { parent: { $ref: `#/$defs/${parentId}` } }, + required: ['parent'], + }, + }); + expect( + convertSchema({}, schemas.child, undefined, context) + ).toStrictEqual({ $ref: `#/$defs/${childId}` }); + }); + }); + describe('other schemas', () => { test('should throw error for unsupported file schema', () => { const schema = v.file(); diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index 831e1c03e..d281e762c 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -97,7 +97,9 @@ type Schema = v.ErrorMessage | undefined > | v.NeverSchema | undefined> - | v.GenericRecursiveSchema + | v.RecursiveSchema + | v.MutualRecursiveSchema + | v.Ref | v.LazySchema>>; /** @@ -155,7 +157,13 @@ export function convertSchema( ): JsonSchema { if (!skipRef) { // If schema is in reference map use reference and skip conversion - const referenceId = context.referenceMap.get(valibotSchema); + const referenceId = + context.referenceMap.get(valibotSchema) ?? + ('wrapped' in valibotSchema && + (valibotSchema.type === 'recursive' || + valibotSchema.type === 'mutualRecursive') + ? context.referenceMap.get(valibotSchema.wrapped) + : undefined); if (referenceId) { jsonSchema.$ref = getDefinitionRef(referenceId); if (config?.overrideRef) { @@ -596,28 +604,47 @@ export function convertSchema( break; } + case 'ref': + case 'mutualRecursive': case 'recursive': { - const recursiveSchema: v.GenericRecursiveSchema = valibotSchema; + const recursiveSchema = valibotSchema; + const wrapped = ( + recursiveSchema.type === 'ref' + ? recursiveSchema.name === undefined + ? recursiveSchema.owner.definition + : ( + recursiveSchema.owner.definition as Record< + string, + v.GenericSchema | v.GenericSchemaAsync + > + )[recursiveSchema.name] + : recursiveSchema.wrapped + ) as SchemaOrPipe; + + // Reuse a registered target without expanding its references again. + if (recursiveSchema.type === 'ref' && context.referenceMap.has(wrapped)) { + return convertSchema(jsonSchema, wrapped, config, context); + } // Get reference ID of recursive Valibot schema - let referenceId = context.referenceMap.get(recursiveSchema); + let referenceId = + context.referenceMap.get(recursiveSchema) ?? + context.referenceMap.get(wrapped); // Add recursive Valibot schema to reference map, if necessary if (!referenceId) { referenceId = context.referenceMap.createId(context.definitions); context.referenceMap.set(recursiveSchema, referenceId); } + context.referenceMap.set(wrapped, referenceId); // Add recursive Valibot schema to definitions, if necessary if ( !Object.prototype.hasOwnProperty.call(context.definitions, referenceId) ) { - const recursiveSelfSchema = recursiveSchema as Parameters< - typeof recursiveSchema.getter - >[0]; context.definitions[referenceId] = convertSchema( {}, - recursiveSchema.getter(recursiveSelfSchema) as SchemaOrPipe, + wrapped, config, context, true diff --git a/website/src/routes/api/(async)/recursiveAsync/properties.ts b/website/src/routes/api/(async)/recursiveAsync/properties.ts index 636ce4e7b..c00e939b3 100644 --- a/website/src/routes/api/(async)/recursiveAsync/properties.ts +++ b/website/src/routes/api/(async)/recursiveAsync/properties.ts @@ -47,8 +47,8 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveSelfSchemaAsync', - href: '../RecursiveSelfSchemaAsync/', + name: 'RecursiveMarkerSchemaAsync', + href: '../RecursiveMarkerSchemaAsync/', }, }, ], diff --git a/website/src/routes/api/(schemas)/recursive/properties.ts b/website/src/routes/api/(schemas)/recursive/properties.ts index 6c75b610c..941b776aa 100644 --- a/website/src/routes/api/(schemas)/recursive/properties.ts +++ b/website/src/routes/api/(schemas)/recursive/properties.ts @@ -27,8 +27,8 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveSelfSchema', - href: '../RecursiveSelfSchema/', + name: 'RecursiveMarkerSchema', + href: '../RecursiveMarkerSchema/', }, }, ], diff --git a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts index 8aab3556e..9ca2e1264 100644 --- a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts +++ b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts @@ -39,8 +39,8 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveSelfSchema', - href: '../RecursiveSelfSchema/', + name: 'RecursiveMarkerSchema', + href: '../RecursiveMarkerSchema/', }, }, ], diff --git a/website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx b/website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx new file mode 100644 index 000000000..5cd428efc --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx @@ -0,0 +1,18 @@ +--- +title: RecursiveMarkerSchema +description: Recursive marker schema interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveMarkerSchema + +Recursive marker schema interface. + +## Definition + +- `RecursiveMarkerSchema` + - `type` diff --git a/website/src/routes/api/(types)/RecursiveSelfSchema/properties.ts b/website/src/routes/api/(types)/RecursiveMarkerSchema/properties.ts similarity index 100% rename from website/src/routes/api/(types)/RecursiveSelfSchema/properties.ts rename to website/src/routes/api/(types)/RecursiveMarkerSchema/properties.ts diff --git a/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx b/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx new file mode 100644 index 000000000..b99b32855 --- /dev/null +++ b/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx @@ -0,0 +1,18 @@ +--- +title: RecursiveMarkerSchemaAsync +description: Recursive marker schema async interface. +contributors: + - yslpn +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RecursiveMarkerSchemaAsync + +Recursive marker schema async interface. + +## Definition + +- `RecursiveMarkerSchemaAsync` + - `type` diff --git a/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/properties.ts b/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/properties.ts similarity index 100% rename from website/src/routes/api/(types)/RecursiveSelfSchemaAsync/properties.ts rename to website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/properties.ts diff --git a/website/src/routes/api/(types)/RecursiveSchema/properties.ts b/website/src/routes/api/(types)/RecursiveSchema/properties.ts index 3d7af8e4f..3962127b6 100644 --- a/website/src/routes/api/(types)/RecursiveSchema/properties.ts +++ b/website/src/routes/api/(types)/RecursiveSchema/properties.ts @@ -102,8 +102,8 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveSelfSchema', - href: '../RecursiveSelfSchema/', + name: 'RecursiveMarkerSchema', + href: '../RecursiveMarkerSchema/', }, }, ], diff --git a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts index 61ae81fb4..a7a7bbfbe 100644 --- a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts +++ b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts @@ -133,8 +133,8 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveSelfSchemaAsync', - href: '../RecursiveSelfSchemaAsync/', + name: 'RecursiveMarkerSchemaAsync', + href: '../RecursiveMarkerSchemaAsync/', }, }, ], diff --git a/website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx b/website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx deleted file mode 100644 index 113cb5e7b..000000000 --- a/website/src/routes/api/(types)/RecursiveSelfSchema/index.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: RecursiveSelfSchema -description: Recursive self schema interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveSelfSchema - -Recursive self schema interface. - -## Definition - -- `RecursiveSelfSchema` - - `type` diff --git a/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx b/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx deleted file mode 100644 index d50c483d6..000000000 --- a/website/src/routes/api/(types)/RecursiveSelfSchemaAsync/index.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: RecursiveSelfSchemaAsync -description: Recursive self schema async interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveSelfSchemaAsync - -Recursive self schema async interface. - -## Definition - -- `RecursiveSelfSchemaAsync` - - `type` diff --git a/website/src/routes/api/menu.md b/website/src/routes/api/menu.md index 520d0e8cc..77a1d196a 100644 --- a/website/src/routes/api/menu.md +++ b/website/src/routes/api/menu.md @@ -672,11 +672,11 @@ - [RecordSchemaAsync](/api/RecordSchemaAsync/) - [RecursiveArray](/api/RecursiveArray/) - [RecursiveMarker](/api/RecursiveMarker/) +- [RecursiveMarkerSchema](/api/RecursiveMarkerSchema/) +- [RecursiveMarkerSchemaAsync](/api/RecursiveMarkerSchemaAsync/) - [RecursiveReadonlyArray](/api/RecursiveReadonlyArray/) - [RecursiveSchema](/api/RecursiveSchema/) - [RecursiveSchemaAsync](/api/RecursiveSchemaAsync/) -- [RecursiveSelfSchema](/api/RecursiveSelfSchema/) -- [RecursiveSelfSchemaAsync](/api/RecursiveSelfSchemaAsync/) - [ReduceItemsAction](/api/ReduceItemsAction/) - [Reference](/api/Reference/) - [RegexAction](/api/RegexAction/) From b5ec6d1bbe7de4debf8059015557f270470168eb Mon Sep 17 00:00:00 2001 From: James Tindal Date: Sun, 8 Nov 2026 22:28:43 +0100 Subject: [PATCH 12/39] deduplicate recursive schema machinery --- library/src/methods/flatten/flatten.test-d.ts | 2 +- .../mutualRecursive/_mutualRecursive.ts | 210 ++++++ library/src/schemas/mutualRecursive/index.ts | 4 +- .../mutualRecursive/mutualRecursive.ts | 611 +----------------- .../mutualRecursiveAsync.execution.test.ts | 2 +- .../mutualRecursiveAsync.test-d.ts | 14 +- .../mutualRecursiveAsync.test.ts | 2 +- .../mutualRecursive/mutualRecursiveAsync.ts | 116 +--- .../src/schemas/recursive/_createRecursive.ts | 63 ++ library/src/schemas/recursive/_recursive.ts | 531 +++++++++++++++ library/src/schemas/recursive/index.ts | 2 +- .../src/schemas/recursive/recursive.test-d.ts | 3 +- .../src/schemas/recursive/recursive.test.ts | 3 +- library/src/schemas/recursive/recursive.ts | 502 +------------- .../recursiveAsync.execution.test.ts | 2 +- .../recursive/recursiveAsync.test-d.ts | 2 +- .../schemas/recursive/recursiveAsync.test.ts | 2 +- .../src/schemas/recursive/recursiveAsync.ts | 33 +- library/src/types/issue.ts | 2 +- .../src/utils/getDotPath/getDotPath.test-d.ts | 2 +- 20 files changed, 878 insertions(+), 1230 deletions(-) create mode 100644 library/src/schemas/mutualRecursive/_mutualRecursive.ts create mode 100644 library/src/schemas/recursive/_createRecursive.ts create mode 100644 library/src/schemas/recursive/_recursive.ts diff --git a/library/src/methods/flatten/flatten.test-d.ts b/library/src/methods/flatten/flatten.test-d.ts index 7e708ca84..f020e54ec 100644 --- a/library/src/methods/flatten/flatten.test-d.ts +++ b/library/src/methods/flatten/flatten.test-d.ts @@ -8,7 +8,7 @@ import type { StringIssue, StringSchema, } from '../../schemas/index.ts'; -import type { Ref } from '../../schemas/recursive/recursive.ts'; +import type { Ref } from '../../schemas/recursive/_recursive.ts'; import type { ArrayPathItem, ObjectPathItem } from '../../types/index.ts'; import { flatten } from './flatten.ts'; diff --git a/library/src/schemas/mutualRecursive/_mutualRecursive.ts b/library/src/schemas/mutualRecursive/_mutualRecursive.ts new file mode 100644 index 000000000..1d2288b8c --- /dev/null +++ b/library/src/schemas/mutualRecursive/_mutualRecursive.ts @@ -0,0 +1,210 @@ +import type { + BaseIssue, + GenericSchema, + GenericSchemaAsync, + InferInput, + InferIssue, + InferOutput, +} from '../../types/index.ts'; +import { + checkRecursiveReferences, + createRecursiveSchema, + createRef, + type RecursiveMarker, + type RecursiveOwner, + type RecursiveReferences, + type Ref, + type RefAsync, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, +} from '../recursive/_recursive.ts'; +import type { MutualRecursiveSchema } from './mutualRecursive.ts'; +import type { MutualRecursiveSchemaAsync } from './mutualRecursiveAsync.ts'; + +/** + * Factory for named synchronous recursive references. + */ +export type RefFactory = ( + name: TName +) => Ref; + +/** + * Factory for named async recursive references. + */ +export type RefFactoryAsync = ( + name: TName +) => RefAsync; + +/** + * Creates a factory for named synchronous recursive references. + * + * @param owner The owner of the references. + * + * @returns A named reference factory. + * + * @internal + */ +export function createRefFactory( + owner: RecursiveOwner & { readonly async: false } +): RefFactory; + +/** + * Creates a factory for named async recursive references. + * + * @param owner The owner of the references. + * + * @returns A named async reference factory. + * + * @internal + */ +export function createRefFactory( + owner: RecursiveOwner & { readonly async: true } +): RefFactoryAsync; +export function createRefFactory(owner: RecursiveOwner) { + return (name: TName) => + createRef(owner, name); +} + +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; +type Entries = Record; + +/** + * Mutual recursive construction options. + * + * @internal + */ +export type MutualConstruction = { + readonly reference: RecursiveOwner['reference']; +} & ( + | { + readonly type: 'mutualRecursive'; + readonly async: false; + readonly build: (ref: RefFactory) => Record; + } + | { + readonly type: 'mutualRecursiveAsync'; + readonly async: true; + readonly build: (ref: RefFactoryAsync) => Entries; + } +); + +/** + * Creates the entries for a mutually recursive schema group. + * + * @param owner The recursive owner and construction options. + * @param setEntries The function that installs the entries. + * + * @returns A map of recursive schemas. + * + * @internal + */ +export function createMutualRecursive( + owner: MutualConstruction & RecursiveOwner, + setEntries: (entries: Entries) => void +): Record { + // @ts-expect-error TS can't correlate the owner's sync/async build callback with its matching reference factory. + const entries = owner.build(createRefFactory(owner)); + setEntries(entries); + const seen = new Set(); + const schemas: Record = Object.create(null); + for (const key of Object.keys(entries)) { + checkRecursiveReferences(entries[key], owner, seen); + schemas[key] = createRecursiveSchema< + Entries[string] & { readonly wrapped: Entries[string] } + >(entries[key], owner, owner.type, owner.reference); + } + return schemas; +} + +type EntryInputs = { + [TKey in keyof TEntries]: InferInput; +}; +type EntryOutputs = { + [TKey in keyof TEntries]: InferOutput; +}; + +type ReferenceNames = + TReference extends RecursiveMarker ? TName : never; + +type UnmatchedReferences = Exclude< + ReferenceNames< + RecursiveReferences | EntryOutputs> + >, + keyof TEntries +>; + +interface Error { + readonly reason: TReason; + readonly impossible: never; +} + +/** + * Rejects references to names absent from the entries. + * + * @internal + */ +export type CheckReferences = [ + UnmatchedReferences, +] extends [never] + ? unknown + : Error<`Unmatched recursive reference: '${Extract, string>}'`>; + +/** + * Resolved mutual recursive schema interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. +export interface ResolvedRecursiveSchema< + TWrapped extends GenericSchema, + TInputRoot, + TOutputRoot, + TIssue extends BaseIssue, +> extends MutualRecursiveSchema< + TWrapped, + ResolveRecursiveInput, TInputRoot>, + ResolveRecursiveOutput, TOutputRoot>, + TIssue + > {} + +/** + * Resolved mutual recursive schema async interface. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. +export interface ResolvedRecursiveSchemaAsync< + TWrapped extends Entries[string], + TInputRoot, + TOutputRoot, + TIssue extends BaseIssue, +> extends MutualRecursiveSchemaAsync< + TWrapped, + ResolveRecursiveInput, TInputRoot>, + ResolveRecursiveOutput, TOutputRoot>, + TIssue + > {} + +/** + * Resolves each synchronous entry against the complete entries map. + * + * @internal + */ +export type RecursiveSchemas> = { + [TName in keyof TEntries]: ResolvedRecursiveSchema< + TEntries[TName], + EntryInputs, + EntryOutputs, + InferIssue + >; +}; + +/** + * Resolves each async entry against the complete entries map. + * + * @internal + */ +export type RecursiveSchemasAsync = { + [TName in keyof TEntries]: ResolvedRecursiveSchemaAsync< + TEntries[TName], + EntryInputs, + EntryOutputs, + InferIssue + >; +}; diff --git a/library/src/schemas/mutualRecursive/index.ts b/library/src/schemas/mutualRecursive/index.ts index 705f2b4bc..062531c3d 100644 --- a/library/src/schemas/mutualRecursive/index.ts +++ b/library/src/schemas/mutualRecursive/index.ts @@ -6,9 +6,9 @@ export type { RefFactory, RefFactoryAsync, ResolvedRecursiveSchema as ResolvedMutualRecursiveSchema, -} from './mutualRecursive.ts'; + ResolvedRecursiveSchemaAsync as ResolvedMutualRecursiveSchemaAsync, +} from './_mutualRecursive.ts'; export { mutualRecursiveAsync, type MutualRecursiveSchemaAsync, - type ResolvedRecursiveSchemaAsync as ResolvedMutualRecursiveSchemaAsync, } from './mutualRecursiveAsync.ts'; diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.ts b/library/src/schemas/mutualRecursive/mutualRecursive.ts index 9dbe8dfdc..08185820b 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursive.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursive.ts @@ -1,251 +1,29 @@ import type { BaseIssue, BaseSchema, - BaseSchemaAsync, - Config, GenericSchema, - GenericSchemaAsync, - InferInput, - InferIssue, - InferOutput, - IsAny, - UnknownDataset, } from '../../types/index.ts'; -import { _standardSchema } from '../../utils/index.ts'; - -/** - * The recursive owner currently running. - * - * @internal - */ -export const OwnerSymbol: unique symbol = Symbol('recursive owner'); - -/** - * Configuration that carries the recursive owner. - * - * @internal - */ -export interface ConfigWithOwner extends Config> { - /** - * The authorized recursive owner. - */ - readonly [OwnerSymbol]?: RecursiveOwner; -} - -// Named interfaces keep recursive array output from collapsing into circular -// type aliases while preserving normal array behavior. -/* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ - -/** - * Recursive array interface. - */ -export interface RecursiveArray - extends Array> {} - -/** - * Recursive readonly array interface. - */ -export interface RecursiveReadonlyArray - extends ReadonlyArray> {} - -/* eslint-enable @typescript-eslint/no-empty-object-type */ - -// Maps each element of a tuple through the resolver, preserving the tuple's -// positional structure (fixed slots, labels, optionality, readonly). -type ResolveRecursiveTuple = { - [TKey in keyof TValue]: ResolveRecursiveValue; -}; - -// Literal tuple branches defer recursive elements without a spread or mapped -// tuple. Optional, readonly, and longer tuples retain the existing resolver. -type ResolveRecursiveFixedTuple< - TValue extends readonly unknown[], - TRoot, -> = TValue extends [infer A] - ? [ResolveRecursiveValue] - : TValue extends [infer A, infer B] - ? [ResolveRecursiveValue, ResolveRecursiveValue] - : TValue extends [infer A, infer B, infer C] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [infer A, infer B, infer C, infer D] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [infer A, infer B, infer C, infer D, infer E] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [ - infer A, - infer B, - infer C, - infer D, - infer E, - infer F, - ] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [ - infer A, - infer B, - infer C, - infer D, - infer E, - infer F, - infer G, - ] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [ - infer A, - infer B, - infer C, - infer D, - infer E, - infer F, - infer G, - infer H, - ] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : ResolveRecursiveTuple; - -type ResolveRecursiveArray< - TValue extends readonly unknown[], - TRoot, -> = number extends TValue['length'] - ? // Variadic tuples (e.g. `[string, ...self[]]`) also have a `number` length, - // but unlike plain arrays carry fixed leading or trailing elements, so map - // element-wise to keep those slots intact. - TValue extends - | readonly [unknown, ...unknown[]] - | readonly [...unknown[], unknown] - ? ResolveRecursiveTuple - : TValue extends (infer TItem)[] - ? true extends ( - TItem extends RecursiveMarker ? true : false - ) - ? RecursiveArray - : ResolveRecursiveValue[] - : TValue extends readonly (infer TItem)[] - ? true extends ( - TItem extends RecursiveMarker ? true : false - ) - ? RecursiveReadonlyArray - : readonly ResolveRecursiveValue[] - : never - : ResolveRecursiveFixedTuple; - -// Rebuilds a container with its type arguments resolved. TypeScript cannot -// reconstruct an arbitrary generic with substituted type arguments, so each -// container kind must be enumerated explicitly. This list must cover every -// container a Valibot schema can infer; an unlisted generic (e.g. `WeakMap`) -// falls through to the object branch, which maps over its methods and leaves -// the marker in its type arguments unresolved. -type ResolveRecursiveContainer = - TValue extends Map - ? Map< - ResolveRecursiveValue, - ResolveRecursiveValue - > - : TValue extends ReadonlyMap - ? ReadonlyMap< - ResolveRecursiveValue, - ResolveRecursiveValue - > - : TValue extends Set - ? Set> - : TValue extends ReadonlySet - ? ReadonlySet> - : TValue extends Promise - ? Promise> - : TValue extends readonly unknown[] - ? ResolveRecursiveArray - : TValue extends object - ? { - [TKey in keyof TValue]: ResolveRecursiveValue< - TValue[TKey], - TRoot - >; - } - : TValue; - -type MergeReference = { - [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; -}; - -type ResolveRecursiveValue = - IsAny extends true - ? TValue - : TValue extends RecursiveMarker - ? TKey extends undefined - ? ResolveRecursiveValue - : TKey extends keyof TRoot - ? Exclude extends never - ? ResolveRecursiveValue - : MergeReference< - ResolveRecursiveValue, - ResolveRecursiveContainer< - Omit, - TRoot - > - > - : never - : [RecursiveReferences] extends [never] - ? TValue - : ResolveRecursiveContainer; - -/** - * Resolve recursive input type. - * - * @internal - */ -export type ResolveRecursiveInput< - TValue, - TRoot = TValue, -> = ResolveRecursiveValue; - -/** - * Resolve recursive output type. - * - * @internal - */ -export type ResolveRecursiveOutput< - TValue, - TRoot = TValue, -> = ResolveRecursiveValue; +import { createRecursive } from '../recursive/_createRecursive.ts'; +import { createRecursiveSchema } from '../recursive/_recursive.ts'; +import { + type CheckReferences, + createMutualRecursive, + type RecursiveSchemas, + type RefFactory, +} from './_mutualRecursive.ts'; + +export type { RefFactory } from './_mutualRecursive.ts'; +export { + OwnerSymbol, + type Ref, + type RecursiveArray, + type RecursiveMarker, + type RecursiveMarkerSymbol, + type RecursiveReadonlyArray, + type RecursiveReferences, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, +} from '../recursive/_recursive.ts'; /** * Recursive schema interface. @@ -263,7 +41,7 @@ export interface MutualRecursiveSchema< /** * The schema reference. */ - readonly reference: typeof createRecursiveSchema; + readonly reference: typeof createRecursiveSchema; /** * The expected property. */ @@ -274,115 +52,8 @@ export interface MutualRecursiveSchema< readonly wrapped: TWrapped; } -type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; - -/** - * Common runtime contract for recursive reference owners. - * - * @internal - */ -export interface RecursiveOwner { - /** - * The callback result that references resolve against. - */ - readonly definition: SyncOrAsyncSchema | Record; - /** - * Whether references execute asynchronously. - */ - readonly async: boolean; - /** - * The owner schema factory. - */ - readonly reference: SyncOrAsyncSchema['reference']; -} - type Entries = Record; -type DefinitionInputs = { - [TKey in keyof TEntries]: InferInput; -}; -type DefinitionOutputs = { - [TKey in keyof TEntries]: InferOutput; -}; - -/** - * Collects recursive references from a value. - */ -export type RecursiveReferences = - IsAny extends true - ? never - : TValue extends RecursiveMarker - ? TValue - : [TValue] extends [TSeen] - ? never - : TValue extends - | Map - | ReadonlyMap - ? RecursiveReferences - : TValue extends - | Set - | ReadonlySet - | Promise - ? RecursiveReferences - : TValue extends readonly (infer TItem)[] - ? RecursiveReferences - : TValue extends object - ? { - [TKey in keyof TValue]-?: RecursiveReferences< - TValue[TKey], - TSeen | TValue - >; - }[keyof TValue] - : never; - -type ReferenceNames = - TReference extends RecursiveMarker ? TName : never; - -type UnmatchedReferences = Exclude< - ReferenceNames< - RecursiveReferences< - DefinitionInputs | DefinitionOutputs - > - >, - keyof TEntries ->; - -interface Error { - readonly reason: TReason; - readonly impossible: never; -} - -type CheckReferences = [ - UnmatchedReferences, -] extends [never] - ? unknown - : Error<`Unmatched recursive reference: '${Extract, string>}'`>; - -/** - * Resolved recursive schema interface. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. -export interface ResolvedRecursiveSchema< - TWrapped extends GenericSchema, - TInputRoot = InferInput, - TOutputRoot = InferOutput, - TIssue extends BaseIssue = InferIssue, -> extends MutualRecursiveSchema< - TWrapped, - ResolveRecursiveInput, TInputRoot>, - ResolveRecursiveOutput, TOutputRoot>, - TIssue - > {} - -type RecursiveSchemas = { - [TName in keyof TEntries]: ResolvedRecursiveSchema< - TEntries[TName], - DefinitionInputs, - DefinitionOutputs, - InferIssue - >; -}; - /** * Creates a map of mutually recursive schemas. * @@ -396,241 +67,11 @@ export function mutualRecursive< build: (ref: RefFactory) => TEntries & CheckReferences ): RecursiveSchemas; export function mutualRecursive(build: (ref: RefFactory) => Entries): unknown { - const owner = { - async: false, - reference: createRecursiveSchema, - get definition(): Entries { - return definitions; - }, - } satisfies RecursiveOwner; - const definitions = build(createRefFactory(owner)); - const seen = new Set(); - const entries: Record = Object.create(null); - for (const key of Object.keys(definitions)) { - checkRecursiveReferences(definitions[key], owner, seen); - entries[key] = createRecursiveSchema(definitions[key], owner); - } - return entries; -} - -function createRecursiveSchema( - root: GenericSchema, - owner: RecursiveOwner -): MutualRecursiveSchema { - return _standardSchema({ - kind: 'schema', + return createRecursive({ type: 'mutualRecursive', - reference: createRecursiveSchema, - expects: 'unknown', async: false, - wrapped: root, - '~run'(dataset, config) { - const executionConfig: ConfigWithOwner = { - ...config, - [OwnerSymbol]: owner, - }; - return root['~run'](dataset, executionConfig); - }, + reference: createRecursiveSchema, + build, + mutual: createMutualRecursive, }); } - -/** - * Rejects references with a foreign owner or an unmatched definition name. - * - * @param value The schema. - * @param owner The owner of the refs. - * @param seen The schemas already visited. - */ -export function checkRecursiveReferences( - value: unknown, - owner: Pick, - seen: Set = new Set() -): void { - if ( - !value || - (typeof value !== 'object' && typeof value !== 'function') || - seen.has(value) - ) { - return; - } - seen.add(value); - const schema = value as Record; - if (schema.kind === 'schema' || schema.kind === 'transformation') { - if (schema.owner !== undefined && schema.owner !== owner) { - throw Error('Recursive reference used outside its recursive schema'); - } - if (schema.owner === owner) { - const name = schema.name as string | undefined; - if (name === undefined) { - if (owner.definition.kind !== 'schema') { - throw Error( - 'Recursive reference in a definitions object must be named' - ); - } - } else if ( - owner.definition.kind === 'schema' || - !Object.hasOwn(owner.definition, name) - ) { - throw Error(`Unmatched recursive reference: '${name}'`); - } - } - } - for (const key in schema) { - if ( - key !== 'owner' && - key !== 'reference' && - key !== '~standard' && - key !== '~types' && - !( - key === 'wrapped' && - [ - 'recursive', - 'recursiveAsync', - 'mutualRecursive', - 'mutualRecursiveAsync', - ].includes(schema.type as string) - ) - ) { - checkRecursiveReferences(schema[key], owner, seen); - } - } -} - -/** - * Recursive marker symbol. - */ -export declare const RecursiveMarkerSymbol: unique symbol; - -/** - * Recursive marker type. - */ -export interface RecursiveMarker { - readonly [RecursiveMarkerSymbol]: TKey; -} - -/** - * Recursive reference interface. - */ -export interface Ref - extends BaseSchema, RecursiveMarker, never> { - /** - * The schema type. - */ - readonly type: 'ref'; - /** - * The recursive schema that owns the reference. - */ - readonly owner: RecursiveOwner; - /** - * The referenced definition name. - */ - readonly name: TName; -} - -/** - * Async recursive reference interface. - */ -export interface RefAsync - extends BaseSchemaAsync< - RecursiveMarker, - RecursiveMarker, - never - > { - /** - * The schema type. - */ - readonly type: 'refAsync'; - /** - * The recursive schema that owns the reference. - */ - readonly owner: RecursiveOwner; - /** - * The referenced definition name. - */ - readonly name: TName; -} - -/** - * Factory for named synchronous recursive references. - */ -export type RefFactory = ( - name: TName -) => Ref; - -/** - * Factory for named async recursive references. - */ -export type RefFactoryAsync = ( - name: TName -) => RefAsync; - -function createRef( - owner: RecursiveOwner & { readonly async: false }, - name?: TName -): Ref; -function createRef( - owner: RecursiveOwner & { readonly async: true }, - name?: TName -): RefAsync; -function createRef( - owner: RecursiveOwner, - name?: TName -): Ref | RefAsync; -function createRef( - owner: RecursiveOwner, - name?: TName -): Ref | RefAsync { - function run(dataset: UnknownDataset, config: ConfigWithOwner) { - if (config[OwnerSymbol] !== owner) { - throw Error('Recursive reference used outside its recursive schema'); - } - if ( - name !== undefined && - (owner.definition.kind === 'schema' || - !Object.hasOwn(owner.definition, name)) - ) { - throw Error(`Unmatched recursive reference: '${name}'`); - } - const target = - name === undefined - ? (owner.definition as SyncOrAsyncSchema) - : (owner.definition as Record)[name]; - return target['~run'](dataset, config); - } - - // The marker describes a type-level reference, not the delegated runtime value. - // The recursive resolver replaces it with the target schema's input and output. - return _standardSchema({ - kind: 'schema' as const, - type: owner.async ? ('refAsync' as const) : ('ref' as const), - expects: 'unknown', - owner, - async: owner.async, - reference: owner.reference, - name, - '~run': owner.async - ? async (dataset: UnknownDataset, config: ConfigWithOwner) => - run(dataset, config) - : run, - } as Omit | RefAsync, '~standard'>); -} - -/** - * Creates a factory for named synchronous recursive references. - * - * @param owner The owner of the references. - * - * @returns A named reference factory. - * - * @internal - */ -export function createRefFactory( - owner: RecursiveOwner & { readonly async: false } -): RefFactory; -export function createRefFactory( - owner: RecursiveOwner & { readonly async: true } -): RefFactoryAsync; -export function createRefFactory(owner: RecursiveOwner) { - return (name: TName) => - createRef(owner, name); -} diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts index cc06bf22f..ceb93edb7 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts @@ -12,7 +12,7 @@ import { objectAsync } from '../object/index.ts'; import { optionalAsync } from '../optional/index.ts'; import { recursiveAsync } from '../recursive/recursiveAsync.ts'; import { string } from '../string/index.ts'; -import type { RefFactoryAsync } from './mutualRecursive.ts'; +import type { RefFactoryAsync } from './_mutualRecursive.ts'; import { mutualRecursive } from './mutualRecursive.ts'; import { mutualRecursiveAsync } from './mutualRecursiveAsync.ts'; diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts index 7f93761dd..b1895c765 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts @@ -19,19 +19,19 @@ import { number, type NumberIssue } from '../number/index.ts'; import { objectAsync as object, type ObjectIssue } from '../object/index.ts'; import { optionalAsync as optional } from '../optional/index.ts'; import { recordAsync as record } from '../record/index.ts'; +import { + type RecursiveMarker, + type RecursiveReferences, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, +} from '../recursive/_recursive.ts'; import { setAsync as set } from '../set/index.ts'; import { string, type StringIssue } from '../string/index.ts'; import { tupleAsync as tuple } from '../tuple/index.ts'; import { tupleWithRestAsync as tupleWithRest } from '../tupleWithRest/index.ts'; import { unionAsync as union, type UnionIssue } from '../union/index.ts'; import { variantAsync as variant } from '../variant/index.ts'; -import type { - RecursiveMarker, - RecursiveReferences, - RefFactoryAsync, - ResolveRecursiveInput, - ResolveRecursiveOutput, -} from './mutualRecursive.ts'; +import type { RefFactoryAsync } from './_mutualRecursive.ts'; import { mutualRecursiveAsync as mutualRecursive, type MutualRecursiveSchemaAsync as MutualRecursiveSchema, diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts index 4197e88d1..90ed86ef6 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test.ts @@ -11,7 +11,7 @@ import { recordAsync } from '../record/index.ts'; import { setAsync } from '../set/index.ts'; import { string, type StringIssue } from '../string/index.ts'; import { tupleAsync } from '../tuple/index.ts'; -import type { RefFactoryAsync } from './mutualRecursive.ts'; +import type { RefFactoryAsync } from './_mutualRecursive.ts'; import { mutualRecursiveAsync as recursiveAsync } from './mutualRecursiveAsync.ts'; interface AInput { diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts index c009fb40a..0a9a2bc6f 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.ts @@ -3,23 +3,15 @@ import type { BaseSchemaAsync, GenericSchema, GenericSchemaAsync, - InferInput, - InferIssue, - InferOutput, } from '../../types/index.ts'; -import { _standardSchema } from '../../utils/index.ts'; +import { createRecursive } from '../recursive/_createRecursive.ts'; +import { createRecursiveSchema } from '../recursive/_recursive.ts'; import { - checkRecursiveReferences, - type ConfigWithOwner, - createRefFactory, - OwnerSymbol, - type RecursiveMarker, - type RecursiveOwner, - type RecursiveReferences, + type CheckReferences, + createMutualRecursive, + type RecursiveSchemasAsync, type RefFactoryAsync, - type ResolveRecursiveInput, - type ResolveRecursiveOutput, -} from './mutualRecursive.ts'; +} from './_mutualRecursive.ts'; /** * Mutual recursive schema async interface. @@ -37,7 +29,7 @@ export interface MutualRecursiveSchemaAsync< /** * The schema reference. */ - readonly reference: typeof createRecursiveSchema; + readonly reference: typeof createRecursiveSchema; /** * The expected property. */ @@ -51,60 +43,6 @@ export interface MutualRecursiveSchemaAsync< type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; type Entries = Record; -type DefinitionInputs = { - [TKey in keyof TEntries]: InferInput; -}; -type DefinitionOutputs = { - [TKey in keyof TEntries]: InferOutput; -}; - -interface Error { - readonly reason: TReason; - readonly impossible: never; -} - -type ReferenceNames = - TReference extends RecursiveMarker ? TName : never; - -type UnmatchedReferences = Exclude< - ReferenceNames< - RecursiveReferences< - DefinitionInputs | DefinitionOutputs - > - >, - keyof TEntries ->; -type CheckReferences = [ - UnmatchedReferences, -] extends [never] - ? unknown - : Error<`Unmatched recursive reference: '${Extract, string>}'`>; - -/** - * Resolved mutual recursive schema async interface. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface defers recursive type resolution. -export interface ResolvedRecursiveSchemaAsync< - TWrapped extends SyncOrAsyncSchema, - TInputRoot, - TOutputRoot, - TIssue extends BaseIssue, -> extends MutualRecursiveSchemaAsync< - TWrapped, - ResolveRecursiveInput, TInputRoot>, - ResolveRecursiveOutput, TOutputRoot>, - TIssue - > {} - -type RecursiveSchemas = { - [TName in keyof TEntries]: ResolvedRecursiveSchemaAsync< - TEntries[TName], - DefinitionInputs, - DefinitionOutputs, - InferIssue - >; -}; - /** * Creates a map of mutually recursive async schemas. * @@ -114,45 +52,15 @@ type RecursiveSchemas = { */ export function mutualRecursiveAsync( build: (ref: RefFactoryAsync) => TEntries & CheckReferences -): RecursiveSchemas; +): RecursiveSchemasAsync; export function mutualRecursiveAsync( build: (ref: RefFactoryAsync) => Entries ): unknown { - const owner = { - async: true, - reference: createRecursiveSchema, - get definition(): Entries { - return definitions; - }, - } satisfies RecursiveOwner; - const definitions = build(createRefFactory(owner)); - const seen = new Set(); - const entries: Record = - Object.create(null); - for (const key of Object.keys(definitions)) { - checkRecursiveReferences(definitions[key], owner, seen); - entries[key] = createRecursiveSchema(definitions[key], owner); - } - return entries; -} - -function createRecursiveSchema( - root: SyncOrAsyncSchema, - owner: RecursiveOwner -): MutualRecursiveSchemaAsync { - return _standardSchema({ - kind: 'schema', + return createRecursive({ type: 'mutualRecursiveAsync', - reference: createRecursiveSchema, - expects: 'unknown', async: true, - wrapped: root, - async '~run'(dataset, config) { - const executionConfig: ConfigWithOwner = { - ...config, - [OwnerSymbol]: owner, - }; - return root['~run'](dataset, executionConfig); - }, + reference: createRecursiveSchema, + build, + mutual: createMutualRecursive, }); } diff --git a/library/src/schemas/recursive/_createRecursive.ts b/library/src/schemas/recursive/_createRecursive.ts new file mode 100644 index 000000000..c307e68b4 --- /dev/null +++ b/library/src/schemas/recursive/_createRecursive.ts @@ -0,0 +1,63 @@ +import type { GenericSchema, GenericSchemaAsync } from '../../types/index.ts'; +import type { + createMutualRecursive, + MutualConstruction, +} from '../mutualRecursive/_mutualRecursive.ts'; +import { + checkRecursiveReferences, + createRecursiveSchema, + createRef, + type RecursiveOwner, + type Ref, + type RefAsync, +} from './_recursive.ts'; + +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; + +type Construction = { readonly reference: RecursiveOwner['reference'] } & ( + | { + readonly type: 'recursive'; + readonly async: false; + readonly build: (self: Ref) => GenericSchema; + } + | { + readonly type: 'recursiveAsync'; + readonly async: true; + readonly build: (self: RefAsync) => SyncOrAsyncSchema; + } + | (MutualConstruction & { + readonly mutual: typeof createMutualRecursive; + }) +); + +/** + * Creates a recursive schema or a map of mutually recursive schemas. + * + * @param construction The schema construction options. + * + * @returns A recursive schema or a map of recursive schemas. + * + * @internal + */ +export function createRecursive(construction: Construction): unknown { + let definition: RecursiveOwner['definition']; + const owner = { + ...construction, + get definition() { + return definition; + }, + } satisfies RecursiveOwner; + + if ('mutual' in owner) { + return owner.mutual(owner, (definitions) => { + definition = definitions; + }); + } + + // @ts-expect-error TS can't correlate the owner's sync/async build callback with its matching reference. + definition = owner.build(createRef(owner)); + checkRecursiveReferences(definition, owner); + return createRecursiveSchema< + SyncOrAsyncSchema & { readonly wrapped: SyncOrAsyncSchema } + >(definition, owner, owner.type, owner.reference); +} diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts new file mode 100644 index 000000000..d59bf9e49 --- /dev/null +++ b/library/src/schemas/recursive/_recursive.ts @@ -0,0 +1,531 @@ +import type { + BaseIssue, + BaseSchema, + BaseSchemaAsync, + Config, + GenericSchema, + GenericSchemaAsync, + IsAny, + UnknownDataset, +} from '../../types/index.ts'; +import { _standardSchema } from '../../utils/_standardSchema/index.ts'; + +// Named interfaces keep recursive array output from collapsing into circular +// type aliases while preserving normal array behavior. +/* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ + +/** + * Recursive array interface. + */ +export interface RecursiveArray + extends Array> {} + +/** + * Recursive readonly array interface. + */ +export interface RecursiveReadonlyArray + extends ReadonlyArray> {} + +/* eslint-enable @typescript-eslint/no-empty-object-type */ + +// Maps each element of a tuple through the resolver, preserving the tuple's +// positional structure (fixed slots, labels, optionality, readonly). +type ResolveRecursiveTuple = { + [TKey in keyof TValue]: ResolveRecursiveValue; +}; + +// Literal tuple branches defer recursive elements without a spread or mapped +// tuple. Optional, readonly, and longer tuples retain the existing resolver. +type ResolveRecursiveFixedTuple< + TValue extends readonly unknown[], + TRoot, +> = TValue extends [infer A] + ? [ResolveRecursiveValue] + : TValue extends [infer A, infer B] + ? [ResolveRecursiveValue, ResolveRecursiveValue] + : TValue extends [infer A, infer B, infer C] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [infer A, infer B, infer C, infer D] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [infer A, infer B, infer C, infer D, infer E] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + infer G, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : TValue extends [ + infer A, + infer B, + infer C, + infer D, + infer E, + infer F, + infer G, + infer H, + ] + ? [ + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ResolveRecursiveValue, + ] + : ResolveRecursiveTuple; + +type ResolveRecursiveArray< + TValue extends readonly unknown[], + TRoot, +> = number extends TValue['length'] + ? // Variadic tuples (e.g. `[string, ...self[]]`) also have a `number` length, + // but unlike plain arrays carry fixed leading or trailing elements, so map + // element-wise to keep those slots intact. + TValue extends + | readonly [unknown, ...unknown[]] + | readonly [...unknown[], unknown] + ? ResolveRecursiveTuple + : TValue extends (infer TItem)[] + ? true extends ( + TItem extends RecursiveMarker ? true : false + ) + ? RecursiveArray + : ResolveRecursiveValue[] + : TValue extends readonly (infer TItem)[] + ? true extends ( + TItem extends RecursiveMarker ? true : false + ) + ? RecursiveReadonlyArray + : readonly ResolveRecursiveValue[] + : never + : ResolveRecursiveFixedTuple; + +// Rebuilds a container with its type arguments resolved. TypeScript cannot +// reconstruct an arbitrary generic with substituted type arguments, so each +// container kind must be enumerated explicitly. This list must cover every +// container a Valibot schema can infer; an unlisted generic (e.g. `WeakMap`) +// falls through to the object branch, which maps over its methods and leaves +// the marker in its type arguments unresolved. +type ResolveRecursiveContainer = + TValue extends Map + ? Map< + ResolveRecursiveValue, + ResolveRecursiveValue + > + : TValue extends ReadonlyMap + ? ReadonlyMap< + ResolveRecursiveValue, + ResolveRecursiveValue + > + : TValue extends Set + ? Set> + : TValue extends ReadonlySet + ? ReadonlySet> + : TValue extends Promise + ? Promise> + : TValue extends readonly unknown[] + ? ResolveRecursiveArray + : TValue extends object + ? { + [TKey in keyof TValue]: ResolveRecursiveValue< + TValue[TKey], + TRoot + >; + } + : TValue; + +type MergeReference = { + [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; +}; + +type ResolveRecursiveValue = + IsAny extends true + ? TValue + : TValue extends RecursiveMarker + ? TKey extends undefined + ? ResolveRecursiveValue + : TKey extends keyof TRoot + ? Exclude extends never + ? ResolveRecursiveValue + : MergeReference< + ResolveRecursiveValue, + ResolveRecursiveContainer< + Omit, + TRoot + > + > + : never + : [RecursiveReferences] extends [never] + ? TValue + : ResolveRecursiveContainer; + +/** + * Resolve recursive input type. + * + * @internal + */ +export type ResolveRecursiveInput< + TValue, + TRoot = TValue, +> = ResolveRecursiveValue; + +/** + * Resolve recursive output type. + * + * @internal + */ +export type ResolveRecursiveOutput< + TValue, + TRoot = TValue, +> = ResolveRecursiveValue; + +type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; + +/** + * Collects recursive references from a value. + */ +export type RecursiveReferences = + IsAny extends true + ? never + : TValue extends RecursiveMarker + ? TValue + : [TValue] extends [TSeen] + ? never + : TValue extends + | Map + | ReadonlyMap + ? RecursiveReferences + : TValue extends + | Set + | ReadonlySet + | Promise + ? RecursiveReferences + : TValue extends readonly (infer TItem)[] + ? RecursiveReferences + : TValue extends object + ? { + [TKey in keyof TValue]-?: RecursiveReferences< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] + : never; + +/** + * The recursive owner currently running. + * + * @internal + */ +export const OwnerSymbol: unique symbol = Symbol('recursive owner'); + +/** + * Configuration that carries the recursive owner. + * + * @internal + */ +export interface ConfigWithOwner extends Config> { + /** + * The authorized recursive owner. + */ + readonly [OwnerSymbol]?: RecursiveOwner; +} + +/** + * Common runtime contract for recursive reference owners. + * + * @internal + */ +export interface RecursiveOwner { + /** + * The callback result that references resolve against. + */ + readonly definition: SyncOrAsyncSchema | Record; + /** + * Whether references execute asynchronously. + */ + readonly async: boolean; + /** + * The owner schema factory. + */ + readonly reference: SyncOrAsyncSchema['reference']; +} + +/** + * Creates an owner-bound recursive schema wrapper. + * + * @param wrapped The wrapped schema. + * @param owner The recursive owner. + * @param type The schema type. + * @param reference The schema factory. + * + * @returns A recursive schema wrapper. + * + * @internal + */ +export function createRecursiveSchema< + TSchema extends Omit & { + readonly wrapped: SyncOrAsyncSchema; + }, +>( + wrapped: TSchema['wrapped'], + owner: RecursiveOwner & { readonly async: TSchema['async'] }, + type: TSchema['type'], + reference: SyncOrAsyncSchema['reference'] +): TSchema; +export function createRecursiveSchema( + wrapped: SyncOrAsyncSchema, + owner: RecursiveOwner, + type: string, + reference: SyncOrAsyncSchema['reference'] +): unknown { + const withOwner = (config: ConfigWithOwner): ConfigWithOwner => ({ + ...config, + [OwnerSymbol]: owner, + }); + const run = (dataset: UnknownDataset, config: ConfigWithOwner) => + wrapped['~run'](dataset, withOwner(config)); + + return _standardSchema< + SyncOrAsyncSchema & { readonly wrapped: SyncOrAsyncSchema } + >({ + kind: 'schema', + type, + reference, + expects: 'unknown', + async: owner.async, + wrapped, + // @ts-expect-error TypeScript cannot correlate owner.async with the wrapped schema's return type. + '~run': owner.async + ? async (dataset: UnknownDataset, config: ConfigWithOwner) => + run(dataset, config) + : run, + }); +} + +/** + * Rejects references with a foreign owner or an unmatched definition name. + * + * @param value The schema. + * @param owner The owner of the refs. + * @param seen The schemas already visited. + */ +export function checkRecursiveReferences( + value: unknown, + owner: Pick, + seen: Set = new Set() +): void { + if ( + !value || + !['object', 'function'].includes(typeof value) || + seen.has(value) + ) { + return; + } + seen.add(value); + const schema = value as Record; + if (['schema', 'transformation'].includes(schema.kind as string)) { + if (schema.owner !== undefined && schema.owner !== owner) { + throw Error('Recursive reference used outside its recursive schema'); + } + if (schema.owner === owner) { + const name = schema.name as string | undefined; + if (name === undefined) { + if (owner.definition.kind !== 'schema') { + throw Error( + 'Recursive reference in a definitions object must be named' + ); + } + } else if ( + owner.definition.kind === 'schema' || + !Object.hasOwn(owner.definition, name) + ) { + throw Error(`Unmatched recursive reference: '${name}'`); + } + } + } + for (const key in schema) { + if ( + !['owner', 'reference', '~standard', '~types'].includes(key) && + !( + key === 'wrapped' && + [ + 'recursive', + 'recursiveAsync', + 'mutualRecursive', + 'mutualRecursiveAsync', + ].includes(schema.type as string) + ) + ) { + checkRecursiveReferences(schema[key], owner, seen); + } + } +} + +/** + * Recursive marker symbol. + */ +export declare const RecursiveMarkerSymbol: unique symbol; + +/** + * Recursive marker type. + */ +export interface RecursiveMarker { + readonly [RecursiveMarkerSymbol]: TKey; +} + +/** + * Recursive reference interface. + */ +export interface Ref + extends BaseSchema, RecursiveMarker, never> { + /** + * The schema type. + */ + readonly type: 'ref'; + /** + * The recursive schema that owns the reference. + */ + readonly owner: RecursiveOwner; + /** + * The referenced definition name. + */ + readonly name: TName; +} + +/** + * Async recursive reference interface. + */ +export interface RefAsync + extends BaseSchemaAsync< + RecursiveMarker, + RecursiveMarker, + never + > { + /** + * The schema type. + */ + readonly type: 'refAsync'; + /** + * The recursive schema that owns the reference. + */ + readonly owner: RecursiveOwner; + /** + * The referenced definition name. + */ + readonly name: TName; +} + +/** + * Creates a reference to a recursive owner's schema. + * + * @param owner The owner of the reference. + * @param name The definition name, for mutual recursion. + * + * @returns A recursive reference. + * + * @internal + */ +export function createRef( + owner: RecursiveOwner & { readonly async: false }, + name?: TName +): Ref; +export function createRef( + owner: RecursiveOwner & { readonly async: true }, + name?: TName +): RefAsync; +export function createRef( + owner: RecursiveOwner, + name?: TName +): Ref | RefAsync; +export function createRef( + owner: RecursiveOwner, + name?: TName +): Ref | RefAsync { + /** + * Runs the referenced schema after checking its owner and definition name. + * + * @param dataset The input dataset. + * @param config The parsing configuration. + * + * @returns The output dataset or a promise of it. + */ + function run(dataset: UnknownDataset, config: ConfigWithOwner) { + if (config[OwnerSymbol] !== owner) { + throw Error('Recursive reference used outside its recursive schema'); + } + if ( + name !== undefined && + (owner.definition.kind === 'schema' || + !Object.hasOwn(owner.definition, name)) + ) { + throw Error(`Unmatched recursive reference: '${name}'`); + } + const target = + name === undefined + ? (owner.definition as SyncOrAsyncSchema) + : (owner.definition as Record)[name]; + return target['~run'](dataset, config); + } + + // The marker describes a type-level reference, not the delegated runtime value. + // The recursive resolver replaces it with the target schema's input and output. + return _standardSchema({ + kind: 'schema' as const, + type: owner.async ? ('refAsync' as const) : ('ref' as const), + expects: 'unknown', + owner, + async: owner.async, + reference: owner.reference, + name, + '~run': owner.async + ? async (dataset: UnknownDataset, config: ConfigWithOwner) => + run(dataset, config) + : run, + } as Omit | RefAsync, '~standard'>); +} diff --git a/library/src/schemas/recursive/index.ts b/library/src/schemas/recursive/index.ts index 81fff683d..3ee45cc21 100644 --- a/library/src/schemas/recursive/index.ts +++ b/library/src/schemas/recursive/index.ts @@ -10,7 +10,7 @@ export { type RecursiveReadonlyArray, type Ref, type RefAsync, -} from './recursive.ts'; +} from './_recursive.ts'; export { recursiveAsync, type RecursiveSchemaAsync, diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index b8a814b19..63196910f 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -26,7 +26,8 @@ import { tuple } from '../tuple/index.ts'; import { tupleWithRest } from '../tupleWithRest/index.ts'; import { union, type UnionIssue, type UnionSchema } from '../union/index.ts'; import { variant } from '../variant/index.ts'; -import { recursive, type RecursiveSchema, type Ref } from './recursive.ts'; +import type { Ref } from './_recursive.ts'; +import { recursive, type RecursiveSchema } from './recursive.ts'; describe('recursive', () => { test('rejects definition maps and callable self references', () => { diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index a19653bb0..3a2e10277 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -7,7 +7,8 @@ import { any } from '../any/index.ts'; import { array } from '../array/index.ts'; import { object } from '../object/index.ts'; import { string } from '../string/index.ts'; -import { recursive, type RecursiveSchema, type Ref } from './recursive.ts'; +import type { Ref } from './_recursive.ts'; +import { recursive, type RecursiveSchema } from './recursive.ts'; describe('recursive', () => { test('should return schema object', () => { diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index 0429b4d4b..51c7355fa 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -1,251 +1,17 @@ import type { BaseIssue, BaseSchema, - BaseSchemaAsync, - Config, GenericSchema, - GenericSchemaAsync, InferInput, InferIssue, InferOutput, - IsAny, - UnknownDataset, } from '../../types/index.ts'; -import { _standardSchema } from '../../utils/index.ts'; - -/** - * The recursive owner currently running. - * - * @internal - */ -export const OwnerSymbol: unique symbol = Symbol('recursive owner'); - -/** - * Configuration that carries the recursive owner. - * - * @internal - */ -export interface ConfigWithOwner extends Config> { - /** - * The authorized recursive owner. - */ - readonly [OwnerSymbol]?: RecursiveOwner; -} - -// Named interfaces keep recursive array output from collapsing into circular -// type aliases while preserving normal array behavior. -/* eslint-disable @typescript-eslint/no-empty-object-type -- These interfaces provide named recursive array indirection for TypeScript. */ - -/** - * Recursive array interface. - */ -export interface RecursiveArray - extends Array> {} - -/** - * Recursive readonly array interface. - */ -export interface RecursiveReadonlyArray - extends ReadonlyArray> {} - -/* eslint-enable @typescript-eslint/no-empty-object-type */ - -// Maps each element of a tuple through the resolver, preserving the tuple's -// positional structure (fixed slots, labels, optionality, readonly). -type ResolveRecursiveTuple = { - [TKey in keyof TValue]: ResolveRecursiveValue; -}; - -// Literal tuple branches defer recursive elements without a spread or mapped -// tuple. Optional, readonly, and longer tuples retain the existing resolver. -type ResolveRecursiveFixedTuple< - TValue extends readonly unknown[], - TRoot, -> = TValue extends [infer A] - ? [ResolveRecursiveValue] - : TValue extends [infer A, infer B] - ? [ResolveRecursiveValue, ResolveRecursiveValue] - : TValue extends [infer A, infer B, infer C] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [infer A, infer B, infer C, infer D] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [infer A, infer B, infer C, infer D, infer E] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [ - infer A, - infer B, - infer C, - infer D, - infer E, - infer F, - ] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [ - infer A, - infer B, - infer C, - infer D, - infer E, - infer F, - infer G, - ] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : TValue extends [ - infer A, - infer B, - infer C, - infer D, - infer E, - infer F, - infer G, - infer H, - ] - ? [ - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ResolveRecursiveValue, - ] - : ResolveRecursiveTuple; - -type ResolveRecursiveArray< - TValue extends readonly unknown[], - TRoot, -> = number extends TValue['length'] - ? // Variadic tuples (e.g. `[string, ...self[]]`) also have a `number` length, - // but unlike plain arrays carry fixed leading or trailing elements, so map - // element-wise to keep those slots intact. - TValue extends - | readonly [unknown, ...unknown[]] - | readonly [...unknown[], unknown] - ? ResolveRecursiveTuple - : TValue extends (infer TItem)[] - ? true extends ( - TItem extends RecursiveMarker ? true : false - ) - ? RecursiveArray - : ResolveRecursiveValue[] - : TValue extends readonly (infer TItem)[] - ? true extends ( - TItem extends RecursiveMarker ? true : false - ) - ? RecursiveReadonlyArray - : readonly ResolveRecursiveValue[] - : never - : ResolveRecursiveFixedTuple; - -// Rebuilds a container with its type arguments resolved. TypeScript cannot -// reconstruct an arbitrary generic with substituted type arguments, so each -// container kind must be enumerated explicitly. This list must cover every -// container a Valibot schema can infer; an unlisted generic (e.g. `WeakMap`) -// falls through to the object branch, which maps over its methods and leaves -// the marker in its type arguments unresolved. -type ResolveRecursiveContainer = - TValue extends Map - ? Map< - ResolveRecursiveValue, - ResolveRecursiveValue - > - : TValue extends ReadonlyMap - ? ReadonlyMap< - ResolveRecursiveValue, - ResolveRecursiveValue - > - : TValue extends Set - ? Set> - : TValue extends ReadonlySet - ? ReadonlySet> - : TValue extends Promise - ? Promise> - : TValue extends readonly unknown[] - ? ResolveRecursiveArray - : TValue extends object - ? { - [TKey in keyof TValue]: ResolveRecursiveValue< - TValue[TKey], - TRoot - >; - } - : TValue; - -type MergeReference = { - [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; -}; - -type ResolveRecursiveValue = - IsAny extends true - ? TValue - : TValue extends RecursiveMarker - ? TKey extends undefined - ? ResolveRecursiveValue - : TKey extends keyof TRoot - ? Exclude extends never - ? ResolveRecursiveValue - : MergeReference< - ResolveRecursiveValue, - ResolveRecursiveContainer< - Omit, - TRoot - > - > - : never - : [RecursiveReferences] extends [never] - ? TValue - : ResolveRecursiveContainer; - -/** - * Resolve recursive input type. - * - * @internal - */ -export type ResolveRecursiveInput< - TValue, - TRoot = TValue, -> = ResolveRecursiveValue; - -/** - * Resolve recursive output type. - * - * @internal - */ -export type ResolveRecursiveOutput< - TValue, - TRoot = TValue, -> = ResolveRecursiveValue; +import { createRecursive } from './_createRecursive.ts'; +import { + type Ref, + type ResolveRecursiveInput, + type ResolveRecursiveOutput, +} from './_recursive.ts'; /** * Recursive schema interface. @@ -274,58 +40,6 @@ export interface RecursiveSchema< readonly wrapped: TWrapped; } -type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; - -/** - * Common runtime contract for recursive reference owners. - * - * @internal - */ -export interface RecursiveOwner { - /** - * The callback result that references resolve against. - */ - readonly definition: SyncOrAsyncSchema | Record; - /** - * Whether references execute asynchronously. - */ - readonly async: boolean; - /** - * The owner schema factory. - */ - readonly reference: SyncOrAsyncSchema['reference']; -} - -/** - * Collects recursive references from a value. - */ -export type RecursiveReferences = - IsAny extends true - ? never - : TValue extends RecursiveMarker - ? TValue - : [TValue] extends [TSeen] - ? never - : TValue extends - | Map - | ReadonlyMap - ? RecursiveReferences - : TValue extends - | Set - | ReadonlySet - | Promise - ? RecursiveReferences - : TValue extends readonly (infer TItem)[] - ? RecursiveReferences - : TValue extends object - ? { - [TKey in keyof TValue]-?: RecursiveReferences< - TValue[TKey], - TSeen | TValue - >; - }[keyof TValue] - : never; - /** * Resolved recursive schema interface. */ @@ -353,208 +67,10 @@ export function recursive( definition: (self: Ref) => TWrapped ): ResolvedRecursiveSchema; export function recursive(definition: (self: Ref) => GenericSchema): unknown { - const owner = { - async: false, - reference: recursive, - get definition(): GenericSchema { - return wrapped; - }, - } satisfies RecursiveOwner; - const wrapped = definition(createRef(owner)); - checkRecursiveReferences(wrapped, owner, new Set()); - return _standardSchema({ - kind: 'schema', + return createRecursive({ type: 'recursive', - reference: recursive, - expects: 'unknown', async: false, - wrapped, - '~run'(dataset, config) { - const executionConfig: ConfigWithOwner = { - ...config, - [OwnerSymbol]: owner, - }; - return wrapped['~run'](dataset, executionConfig); - }, + reference: recursive, + build: definition, }); } - -/** - * Rejects references with a foreign owner or an unmatched definition name. - * - * @param value The schema. - * @param owner The owner of the refs. - * @param seen The schemas already visited. - */ -export function checkRecursiveReferences( - value: unknown, - owner: Pick, - seen: Set = new Set() -): void { - if ( - !value || - (typeof value !== 'object' && typeof value !== 'function') || - seen.has(value) - ) { - return; - } - seen.add(value); - const schema = value as Record; - if (schema.kind === 'schema' || schema.kind === 'transformation') { - if (schema.owner !== undefined && schema.owner !== owner) { - throw Error('Recursive reference used outside its recursive schema'); - } - if (schema.owner === owner) { - const name = schema.name as string | undefined; - if (name === undefined) { - if (owner.definition.kind !== 'schema') { - throw Error( - 'Recursive reference in a definitions object must be named' - ); - } - } else if ( - owner.definition.kind === 'schema' || - !Object.hasOwn(owner.definition, name) - ) { - throw Error(`Unmatched recursive reference: '${name}'`); - } - } - } - for (const key in schema) { - if ( - key !== 'owner' && - key !== 'reference' && - key !== '~standard' && - key !== '~types' && - !( - key === 'wrapped' && - [ - 'recursive', - 'recursiveAsync', - 'mutualRecursive', - 'mutualRecursiveAsync', - ].includes(schema.type as string) - ) - ) { - checkRecursiveReferences(schema[key], owner, seen); - } - } -} - -/** - * Recursive marker symbol. - */ -export declare const RecursiveMarkerSymbol: unique symbol; - -/** - * Recursive marker type. - */ -export interface RecursiveMarker { - readonly [RecursiveMarkerSymbol]: TKey; -} - -/** - * Recursive reference interface. - */ -export interface Ref - extends BaseSchema, RecursiveMarker, never> { - /** - * The schema type. - */ - readonly type: 'ref'; - /** - * The recursive schema that owns the reference. - */ - readonly owner: RecursiveOwner; - /** - * The referenced definition name. - */ - readonly name: TName; -} - -/** - * Async recursive reference interface. - */ -export interface RefAsync - extends BaseSchemaAsync< - RecursiveMarker, - RecursiveMarker, - never - > { - /** - * The schema type. - */ - readonly type: 'refAsync'; - /** - * The recursive schema that owns the reference. - */ - readonly owner: RecursiveOwner; - /** - * The referenced definition name. - */ - readonly name: TName; -} - -/** - * Creates a reference to a recursive owner's schema. - * - * @param owner The owner of the reference. - * @param name The definition name, for mutual recursion. - * - * @returns A recursive reference. - * - * @internal - */ -export function createRef( - owner: RecursiveOwner & { readonly async: false }, - name?: TName -): Ref; -export function createRef( - owner: RecursiveOwner & { readonly async: true }, - name?: TName -): RefAsync; -export function createRef( - owner: RecursiveOwner, - name?: TName -): Ref | RefAsync; -export function createRef( - owner: RecursiveOwner, - name?: TName -): Ref | RefAsync { - function run(dataset: UnknownDataset, config: ConfigWithOwner) { - if (config[OwnerSymbol] !== owner) { - throw Error('Recursive reference used outside its recursive schema'); - } - if ( - name !== undefined && - (owner.definition.kind === 'schema' || - !Object.hasOwn(owner.definition, name)) - ) { - throw Error(`Unmatched recursive reference: '${name}'`); - } - const target = - name === undefined - ? (owner.definition as SyncOrAsyncSchema) - : (owner.definition as Record)[name]; - return target['~run'](dataset, config); - } - - const reference = { - kind: 'schema' as const, - type: owner.async ? ('refAsync' as const) : ('ref' as const), - expects: 'unknown', - owner, - async: owner.async, - reference: owner.reference, - name, - '~run': owner.async - ? async (dataset: UnknownDataset, config: ConfigWithOwner) => - run(dataset, config) - : run, - }; - // The marker describes a type-level reference, not the delegated runtime value. - // The recursive resolver replaces it with the target schema's input and output. - return _standardSchema | RefAsync>( - reference as Omit | RefAsync, '~standard'> - ); -} diff --git a/library/src/schemas/recursive/recursiveAsync.execution.test.ts b/library/src/schemas/recursive/recursiveAsync.execution.test.ts index c93a23009..dab47ce56 100644 --- a/library/src/schemas/recursive/recursiveAsync.execution.test.ts +++ b/library/src/schemas/recursive/recursiveAsync.execution.test.ts @@ -10,7 +10,7 @@ import { arrayAsync } from '../array/index.ts'; import { objectAsync } from '../object/index.ts'; import { optional } from '../optional/index.ts'; import { string } from '../string/index.ts'; -import type { RefAsync } from './recursive.ts'; +import type { RefAsync } from './_recursive.ts'; import { recursiveAsync } from './recursiveAsync.ts'; describe('recursiveAsync execution', () => { diff --git a/library/src/schemas/recursive/recursiveAsync.test-d.ts b/library/src/schemas/recursive/recursiveAsync.test-d.ts index fe5c6a64d..820faf5aa 100644 --- a/library/src/schemas/recursive/recursiveAsync.test-d.ts +++ b/library/src/schemas/recursive/recursiveAsync.test-d.ts @@ -29,7 +29,7 @@ import { import { tupleAsync } from '../tuple/index.ts'; import type { UnionIssue, UnionSchemaAsync } from '../union/index.ts'; import { variantAsync } from '../variant/index.ts'; -import type { RefAsync } from './recursive.ts'; +import type { RefAsync } from './_recursive.ts'; import { recursiveAsync, type RecursiveSchemaAsync } from './recursiveAsync.ts'; describe('recursiveAsync', () => { diff --git a/library/src/schemas/recursive/recursiveAsync.test.ts b/library/src/schemas/recursive/recursiveAsync.test.ts index 7670b2795..725b3cfc9 100644 --- a/library/src/schemas/recursive/recursiveAsync.test.ts +++ b/library/src/schemas/recursive/recursiveAsync.test.ts @@ -5,7 +5,7 @@ import { expectNoSchemaIssueAsync } from '../../vitest/index.ts'; import { array, arrayAsync } from '../array/index.ts'; import { object, objectAsync } from '../object/index.ts'; import { string } from '../string/index.ts'; -import type { RefAsync } from './recursive.ts'; +import type { RefAsync } from './_recursive.ts'; import { recursive } from './recursive.ts'; import { recursiveAsync, type RecursiveSchemaAsync } from './recursiveAsync.ts'; diff --git a/library/src/schemas/recursive/recursiveAsync.ts b/library/src/schemas/recursive/recursiveAsync.ts index 5318d6cb5..abd4e1291 100644 --- a/library/src/schemas/recursive/recursiveAsync.ts +++ b/library/src/schemas/recursive/recursiveAsync.ts @@ -7,17 +7,12 @@ import type { InferIssue, InferOutput, } from '../../types/index.ts'; -import { _standardSchema } from '../../utils/index.ts'; +import { createRecursive } from './_createRecursive.ts'; import { - checkRecursiveReferences, - type ConfigWithOwner, - createRef, - OwnerSymbol, - type RecursiveOwner, type RefAsync, type ResolveRecursiveInput, type ResolveRecursiveOutput, -} from './recursive.ts'; +} from './_recursive.ts'; /** * Recursive schema async interface. @@ -76,28 +71,10 @@ export function recursiveAsync( export function recursiveAsync( definition: (self: RefAsync) => SyncOrAsyncSchema ): unknown { - const owner = { - async: true, - reference: recursiveAsync, - get definition() { - return wrapped; - }, - } satisfies RecursiveOwner; - const wrapped = definition(createRef(owner)); - checkRecursiveReferences(wrapped, owner); - return _standardSchema({ - kind: 'schema', + return createRecursive({ type: 'recursiveAsync', - reference: recursiveAsync, - expects: 'unknown', async: true, - wrapped, - async '~run'(dataset, config) { - const configWithOwner: ConfigWithOwner = { - ...config, - [OwnerSymbol]: owner, - }; - return wrapped['~run'](dataset, configWithOwner); - }, + reference: recursiveAsync, + build: definition, }); } diff --git a/library/src/types/issue.ts b/library/src/types/issue.ts index d400d048f..a9bd77af2 100644 --- a/library/src/types/issue.ts +++ b/library/src/types/issue.ts @@ -71,7 +71,7 @@ import type { VariantSchema, VariantSchemaAsync, } from '../schemas/index.ts'; -import type { Ref, RefAsync } from '../schemas/recursive/recursive.ts'; +import type { Ref, RefAsync } from '../schemas/recursive/_recursive.ts'; import type { Config } from './config.ts'; import type { InferInput } from './infer.ts'; import type { ObjectEntries, ObjectEntriesAsync } from './object.ts'; diff --git a/library/src/utils/getDotPath/getDotPath.test-d.ts b/library/src/utils/getDotPath/getDotPath.test-d.ts index 416df11fd..11754e4e7 100644 --- a/library/src/utils/getDotPath/getDotPath.test-d.ts +++ b/library/src/utils/getDotPath/getDotPath.test-d.ts @@ -10,7 +10,7 @@ import type { StringIssue, StringSchema, } from '../../schemas/index.ts'; -import type { RefAsync } from '../../schemas/recursive/recursive.ts'; +import type { RefAsync } from '../../schemas/recursive/_recursive.ts'; import type { ArrayPathItem, ObjectPathItem } from '../../types/index.ts'; import { getDotPath } from './getDotPath.ts'; From 2df2f150057f27c464bce75b2e267cb5bdf791aa Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 00:07:50 +0100 Subject: [PATCH 13/39] docs: document recursive and mutual recursive schemas --- .../(async)/mutualRecursiveAsync/index.mdx | 106 +++++++++++++++ .../mutualRecursiveAsync/properties.ts | 54 ++++++++ .../api/(async)/recursiveAsync/index.mdx | 24 +--- .../api/(async)/recursiveAsync/properties.ts | 18 +-- .../api/(schemas)/mutualRecursive/index.mdx | 86 ++++++++++++ .../(schemas)/mutualRecursive/properties.ts | 40 ++++++ .../routes/api/(schemas)/recursive/index.mdx | 9 +- .../api/(schemas)/recursive/properties.ts | 9 +- .../(types)/GenericRecursiveSchema/index.mdx | 20 --- .../GenericRecursiveSchema/properties.ts | 64 --------- .../(types)/MutualRecursiveSchema/index.mdx | 29 +++++ .../MutualRecursiveSchema/properties.ts | 53 ++++++++ .../MutualRecursiveSchemaAsync/index.mdx | 29 +++++ .../MutualRecursiveSchemaAsync/properties.ts | 63 +++++++++ .../api/(types)/RecursiveArray/index.mdx | 22 ---- .../api/(types)/RecursiveArray/properties.ts | 35 ----- .../api/(types)/RecursiveMarker/index.mdx | 17 --- .../api/(types)/RecursiveMarker/properties.ts | 11 -- .../(types)/RecursiveMarkerSchema/index.mdx | 18 --- .../RecursiveMarkerSchemaAsync/index.mdx | 18 --- .../(types)/RecursiveReadonlyArray/index.mdx | 22 ---- .../RecursiveReadonlyArray/properties.ts | 35 ----- .../api/(types)/RecursiveSchema/index.mdx | 8 +- .../api/(types)/RecursiveSchema/properties.ts | 86 ++++-------- .../(types)/RecursiveSchemaAsync/index.mdx | 8 +- .../RecursiveSchemaAsync/properties.ts | 122 ++++++------------ website/src/routes/api/(types)/Ref/index.mdx | 25 ++++ .../properties.ts | 19 ++- .../src/routes/api/(types)/RefAsync/index.mdx | 25 ++++ .../properties.ts | 19 ++- website/src/routes/api/menu.md | 12 +- .../guides/(main-concepts)/schemas/index.mdx | 6 + .../routes/guides/(schemas)/other/index.mdx | 26 ++-- 33 files changed, 660 insertions(+), 478 deletions(-) create mode 100644 website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx create mode 100644 website/src/routes/api/(async)/mutualRecursiveAsync/properties.ts create mode 100644 website/src/routes/api/(schemas)/mutualRecursive/index.mdx create mode 100644 website/src/routes/api/(schemas)/mutualRecursive/properties.ts delete mode 100644 website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx delete mode 100644 website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts create mode 100644 website/src/routes/api/(types)/MutualRecursiveSchema/index.mdx create mode 100644 website/src/routes/api/(types)/MutualRecursiveSchema/properties.ts create mode 100644 website/src/routes/api/(types)/MutualRecursiveSchemaAsync/index.mdx create mode 100644 website/src/routes/api/(types)/MutualRecursiveSchemaAsync/properties.ts delete mode 100644 website/src/routes/api/(types)/RecursiveArray/index.mdx delete mode 100644 website/src/routes/api/(types)/RecursiveArray/properties.ts delete mode 100644 website/src/routes/api/(types)/RecursiveMarker/index.mdx delete mode 100644 website/src/routes/api/(types)/RecursiveMarker/properties.ts delete mode 100644 website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx delete mode 100644 website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx delete mode 100644 website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx delete mode 100644 website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts create mode 100644 website/src/routes/api/(types)/Ref/index.mdx rename website/src/routes/api/(types)/{RecursiveMarkerSchema => Ref}/properties.ts (55%) create mode 100644 website/src/routes/api/(types)/RefAsync/index.mdx rename website/src/routes/api/(types)/{RecursiveMarkerSchemaAsync => RefAsync}/properties.ts (55%) diff --git a/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx b/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx new file mode 100644 index 000000000..c89ec9775 --- /dev/null +++ b/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx @@ -0,0 +1,106 @@ +--- +title: mutualRecursiveAsync +description: Creates a map of mutually recursive async schemas. +source: /schemas/mutualRecursive/mutualRecursiveAsync.ts +contributors: + - yslpn + - james-tindal +--- + +import { ApiList, Link, Property } from '~/components'; +import { properties } from './properties'; + +# mutualRecursiveAsync + +Creates a map of mutually recursive async schemas. + +```ts +const Schemas = v.mutualRecursiveAsync(build); +``` + +## Generics + +- `TEntries` + +## Parameters + +- `build` + +### Explanation + +The `build` callback receives a `ref` function. Call `ref` with the key of another entry to reference that entry inside the returned object. Valibot infers the recursive input and output types of each entry from this object. + +> If a name does not match an entry, TypeScript reports an error. At runtime, Valibot throws an error for the same case. + +## Returns + +- `Schemas` + +## Examples + +The following examples show how `mutualRecursiveAsync` can be used. + +### Category and product schemas + +Schemas that reference each other. + +```ts +const { category, product } = v.mutualRecursiveAsync((ref) => ({ + category: v.objectAsync({ + name: v.string(), + products: v.arrayAsync(ref('product')), + }), + product: v.objectAsync({ + name: v.string(), + category: v.optionalAsync(ref('category')), + }), +})); +``` + +## Related + +The following APIs can be combined with `mutualRecursiveAsync`. + +### Schemas + + + +### Methods + + + +### Actions + + + +### Async + + diff --git a/website/src/routes/api/(async)/mutualRecursiveAsync/properties.ts b/website/src/routes/api/(async)/mutualRecursiveAsync/properties.ts new file mode 100644 index 000000000..901e835e5 --- /dev/null +++ b/website/src/routes/api/(async)/mutualRecursiveAsync/properties.ts @@ -0,0 +1,54 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TEntries: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Record', + generics: [ + 'string', + { + type: 'union', + options: [ + { + type: 'custom', + name: 'GenericSchema', + href: '../GenericSchema/', + }, + { + type: 'custom', + name: 'GenericSchemaAsync', + href: '../GenericSchemaAsync/', + }, + ], + }, + ], + }, + }, + build: { + type: { + type: 'function', + params: [ + { + name: 'ref', + type: { + type: 'custom', + name: 'RefFactoryAsync', + }, + }, + ], + return: { + type: 'custom', + name: 'TEntries', + }, + }, + }, + Schemas: { + type: { + type: 'custom', + name: 'RecursiveSchemasAsync', + generics: [{ type: 'custom', name: 'TEntries' }], + }, + }, +}; diff --git a/website/src/routes/api/(async)/recursiveAsync/index.mdx b/website/src/routes/api/(async)/recursiveAsync/index.mdx index d3fe49847..8b2431e6e 100644 --- a/website/src/routes/api/(async)/recursiveAsync/index.mdx +++ b/website/src/routes/api/(async)/recursiveAsync/index.mdx @@ -4,6 +4,7 @@ description: Creates a recursive schema. source: /schemas/recursive/recursiveAsync.ts contributors: - yslpn + - james-tindal --- import { ApiList, Link, Property } from '~/components'; @@ -14,7 +15,7 @@ import { properties } from './properties'; Creates a recursive schema. ```ts -const Schema = v.recursiveAsync(getter); +const Schema = v.recursiveAsync(definition); ``` ## Generics @@ -23,11 +24,13 @@ const Schema = v.recursiveAsync(getter); ## Parameters -- `getter` +- `definition` ### Explanation -The `getter` function receives a `self` schema that can be used inside the schema returned by `getter`. This allows Valibot to infer recursive input and output types automatically. +The `definition` callback receives a `self` reference for use inside the returned schema. Valibot infers recursive input and output types from this definition. + +The callback returns a schema directly, not a promise. The returned schema can perform asynchronous validation. > When you nest `self`, use async-compatible schemas such as `objectAsync` and `arrayAsync`. @@ -52,19 +55,6 @@ const CategorySchema = v.recursiveAsync((self) => ); ``` -### Async getter schema - -Recursive schema with an async getter. - -```ts -const CategorySchema = v.recursiveAsync(async (self) => - v.objectAsync({ - name: v.string(), - subcategories: v.arrayAsync(self), - }) -); -``` - ## Related The following APIs can be combined with `recursiveAsync`. @@ -86,7 +76,7 @@ The following APIs can be combined with `recursiveAsync`. ### Methods - + ### Actions diff --git a/website/src/routes/api/(async)/recursiveAsync/properties.ts b/website/src/routes/api/(async)/recursiveAsync/properties.ts index c00e939b3..041076086 100644 --- a/website/src/routes/api/(async)/recursiveAsync/properties.ts +++ b/website/src/routes/api/(async)/recursiveAsync/properties.ts @@ -39,7 +39,7 @@ export const properties: Record = { ], }, }, - getter: { + definition: { type: { type: 'function', params: [ @@ -47,29 +47,21 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveMarkerSchemaAsync', - href: '../RecursiveMarkerSchemaAsync/', + name: 'RefAsync', + href: '../RefAsync/', }, }, ], return: { type: 'custom', - name: 'MaybePromise', - href: '../MaybePromise/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], + name: 'TWrapped', }, }, }, Schema: { type: { type: 'custom', - name: 'RecursiveSchemaAsync', - href: '../RecursiveSchemaAsync/', + name: 'ResolvedRecursiveSchemaAsync', generics: [ { type: 'custom', diff --git a/website/src/routes/api/(schemas)/mutualRecursive/index.mdx b/website/src/routes/api/(schemas)/mutualRecursive/index.mdx new file mode 100644 index 000000000..28f42110c --- /dev/null +++ b/website/src/routes/api/(schemas)/mutualRecursive/index.mdx @@ -0,0 +1,86 @@ +--- +title: mutualRecursive +description: Creates a map of mutually recursive schemas. +source: /schemas/mutualRecursive/mutualRecursive.ts +contributors: + - yslpn + - james-tindal +--- + +import { ApiList, Link, Property } from '~/components'; +import { properties } from './properties'; + +# mutualRecursive + +Creates a map of mutually recursive schemas. + +```ts +const Schemas = v.mutualRecursive(build); +``` + +## Generics + +- `TEntries` + +## Parameters + +- `build` + +### Explanation + +The `build` callback receives a `ref` function. Call `ref` with the key of another entry to reference that entry inside the returned object. Valibot infers the recursive input and output types of each entry from this object. + +> If a name does not match an entry, TypeScript reports an error. At runtime, Valibot throws an error for the same case. + +## Returns + +- `Schemas` + +## Examples + +The following examples show how `mutualRecursive` can be used. + +### Category and product schemas + +Schemas that reference each other. + +```ts +const { category, product } = v.mutualRecursive((ref) => ({ + category: v.object({ + name: v.string(), + products: v.array(ref('product')), + }), + product: v.object({ + name: v.string(), + category: v.optional(ref('category')), + }), +})); +``` + +## Related + +The following APIs can be combined with `mutualRecursive`. + +### Schemas + + + +### Methods + + + +### Actions + + diff --git a/website/src/routes/api/(schemas)/mutualRecursive/properties.ts b/website/src/routes/api/(schemas)/mutualRecursive/properties.ts new file mode 100644 index 000000000..f3362979f --- /dev/null +++ b/website/src/routes/api/(schemas)/mutualRecursive/properties.ts @@ -0,0 +1,40 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TEntries: { + modifier: 'extends', + type: { + type: 'custom', + name: 'Record', + generics: [ + 'string', + { type: 'custom', name: 'GenericSchema', href: '../GenericSchema/' }, + ], + }, + }, + build: { + type: { + type: 'function', + params: [ + { + name: 'ref', + type: { + type: 'custom', + name: 'RefFactory', + }, + }, + ], + return: { + type: 'custom', + name: 'TEntries', + }, + }, + }, + Schemas: { + type: { + type: 'custom', + name: 'RecursiveSchemas', + generics: [{ type: 'custom', name: 'TEntries' }], + }, + }, +}; diff --git a/website/src/routes/api/(schemas)/recursive/index.mdx b/website/src/routes/api/(schemas)/recursive/index.mdx index 3bdea6225..e4fb7092f 100644 --- a/website/src/routes/api/(schemas)/recursive/index.mdx +++ b/website/src/routes/api/(schemas)/recursive/index.mdx @@ -4,6 +4,7 @@ description: Creates a recursive schema. source: /schemas/recursive/recursive.ts contributors: - yslpn + - james-tindal --- import { ApiList, Link, Property } from '~/components'; @@ -14,7 +15,7 @@ import { properties } from './properties'; Creates a recursive schema. ```ts -const Schema = v.recursive(getter); +const Schema = v.recursive(definition); ``` ## Generics @@ -23,13 +24,13 @@ const Schema = v.recursive(getter); ## Parameters -- `getter` +- `definition` ### Explanation -The `getter` function receives a `self` schema that can be used inside the schema returned by `getter`. This allows Valibot to infer recursive input and output types automatically. +The `definition` callback receives a `self` reference for use inside the returned schema. Valibot infers recursive input and output types from this definition. -> If you need to choose a schema based on the input value, use `lazy` instead. +For schemas that reference each other, use `mutualRecursive`. ## Returns diff --git a/website/src/routes/api/(schemas)/recursive/properties.ts b/website/src/routes/api/(schemas)/recursive/properties.ts index 941b776aa..066141b84 100644 --- a/website/src/routes/api/(schemas)/recursive/properties.ts +++ b/website/src/routes/api/(schemas)/recursive/properties.ts @@ -19,7 +19,7 @@ export const properties: Record = { ], }, }, - getter: { + definition: { type: { type: 'function', params: [ @@ -27,8 +27,8 @@ export const properties: Record = { name: 'self', type: { type: 'custom', - name: 'RecursiveMarkerSchema', - href: '../RecursiveMarkerSchema/', + name: 'Ref', + href: '../Ref/', }, }, ], @@ -41,8 +41,7 @@ export const properties: Record = { Schema: { type: { type: 'custom', - name: 'RecursiveSchema', - href: '../RecursiveSchema/', + name: 'ResolvedRecursiveSchema', generics: [ { type: 'custom', diff --git a/website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx b/website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx deleted file mode 100644 index c8b1668c7..000000000 --- a/website/src/routes/api/(types)/GenericRecursiveSchema/index.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: GenericRecursiveSchema -description: Generic recursive schema interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# GenericRecursiveSchema - -Generic recursive schema interface. - -## Definition - -- `GenericRecursiveSchema` - - `type` - - `expects` - - `getter` diff --git a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts b/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts deleted file mode 100644 index 9ca2e1264..000000000 --- a/website/src/routes/api/(types)/GenericRecursiveSchema/properties.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { PropertyProps } from '~/components'; - -export const properties: Record = { - BaseSchema: { - modifier: 'extends', - type: { - type: 'custom', - name: 'BaseSchema', - href: '../BaseSchema/', - generics: [ - 'unknown', - 'unknown', - { - type: 'custom', - name: 'BaseIssue', - href: '../BaseIssue/', - generics: ['unknown'], - }, - ], - }, - }, - type: { - type: { - type: 'string', - value: 'recursive', - }, - }, - expects: { - type: { - type: 'string', - value: 'unknown', - }, - }, - getter: { - type: { - type: 'function', - params: [ - { - name: 'self', - type: { - type: 'custom', - name: 'RecursiveMarkerSchema', - href: '../RecursiveMarkerSchema/', - }, - }, - ], - return: { - type: 'custom', - name: 'BaseSchema', - href: '../BaseSchema/', - generics: [ - 'unknown', - 'unknown', - { - type: 'custom', - name: 'BaseIssue', - href: '../BaseIssue/', - generics: ['unknown'], - }, - ], - }, - }, - }, -}; diff --git a/website/src/routes/api/(types)/MutualRecursiveSchema/index.mdx b/website/src/routes/api/(types)/MutualRecursiveSchema/index.mdx new file mode 100644 index 000000000..3d26f6361 --- /dev/null +++ b/website/src/routes/api/(types)/MutualRecursiveSchema/index.mdx @@ -0,0 +1,29 @@ +--- +title: MutualRecursiveSchema +description: Mutual recursive schema interface. +contributors: + - yslpn + - james-tindal +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# MutualRecursiveSchema + +Mutual recursive schema interface. + +## Generics + +- `TWrapped` +- `TInput` +- `TOutput` +- `TIssue` + +## Definition + +- `MutualRecursiveSchema` + - `type` + - `reference` + - `expects` + - `wrapped` diff --git a/website/src/routes/api/(types)/MutualRecursiveSchema/properties.ts b/website/src/routes/api/(types)/MutualRecursiveSchema/properties.ts new file mode 100644 index 000000000..3658d01e7 --- /dev/null +++ b/website/src/routes/api/(types)/MutualRecursiveSchema/properties.ts @@ -0,0 +1,53 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TWrapped: { + modifier: 'extends', + type: { type: 'custom', name: 'GenericSchema', href: '../GenericSchema/' }, + }, + TInput: { + type: 'unknown', + }, + TOutput: { + type: 'unknown', + }, + TIssue: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + }, + BaseSchema: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchema', + href: '../BaseSchema/', + generics: [ + { type: 'custom', name: 'TInput' }, + { type: 'custom', name: 'TOutput' }, + { type: 'custom', name: 'TIssue' }, + ], + }, + }, + type: { + type: { type: 'string', value: 'mutualRecursive' }, + }, + reference: { + type: { + type: 'custom', + modifier: 'typeof', + name: 'createRecursiveSchema', + generics: [{ type: 'custom', name: 'MutualRecursiveSchema' }], + }, + }, + expects: { + type: { type: 'string', value: 'unknown' }, + }, + wrapped: { + type: { type: 'custom', name: 'TWrapped' }, + }, +}; diff --git a/website/src/routes/api/(types)/MutualRecursiveSchemaAsync/index.mdx b/website/src/routes/api/(types)/MutualRecursiveSchemaAsync/index.mdx new file mode 100644 index 000000000..4564ec91e --- /dev/null +++ b/website/src/routes/api/(types)/MutualRecursiveSchemaAsync/index.mdx @@ -0,0 +1,29 @@ +--- +title: MutualRecursiveSchemaAsync +description: Mutual recursive schema async interface. +contributors: + - yslpn + - james-tindal +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# MutualRecursiveSchemaAsync + +Mutual recursive schema async interface. + +## Generics + +- `TWrapped` +- `TInput` +- `TOutput` +- `TIssue` + +## Definition + +- `MutualRecursiveSchemaAsync` + - `type` + - `reference` + - `expects` + - `wrapped` diff --git a/website/src/routes/api/(types)/MutualRecursiveSchemaAsync/properties.ts b/website/src/routes/api/(types)/MutualRecursiveSchemaAsync/properties.ts new file mode 100644 index 000000000..463b43f6b --- /dev/null +++ b/website/src/routes/api/(types)/MutualRecursiveSchemaAsync/properties.ts @@ -0,0 +1,63 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + TWrapped: { + modifier: 'extends', + type: { + type: 'union', + options: [ + { type: 'custom', name: 'GenericSchema', href: '../GenericSchema/' }, + { + type: 'custom', + name: 'GenericSchemaAsync', + href: '../GenericSchemaAsync/', + }, + ], + }, + }, + TInput: { + type: 'unknown', + }, + TOutput: { + type: 'unknown', + }, + TIssue: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + }, + BaseSchemaAsync: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseSchemaAsync', + href: '../BaseSchemaAsync/', + generics: [ + { type: 'custom', name: 'TInput' }, + { type: 'custom', name: 'TOutput' }, + { type: 'custom', name: 'TIssue' }, + ], + }, + }, + type: { + type: { type: 'string', value: 'mutualRecursiveAsync' }, + }, + reference: { + type: { + type: 'custom', + modifier: 'typeof', + name: 'createRecursiveSchema', + generics: [{ type: 'custom', name: 'MutualRecursiveSchemaAsync' }], + }, + }, + expects: { + type: { type: 'string', value: 'unknown' }, + }, + wrapped: { + type: { type: 'custom', name: 'TWrapped' }, + }, +}; diff --git a/website/src/routes/api/(types)/RecursiveArray/index.mdx b/website/src/routes/api/(types)/RecursiveArray/index.mdx deleted file mode 100644 index 865700198..000000000 --- a/website/src/routes/api/(types)/RecursiveArray/index.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: RecursiveArray -description: Recursive array interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveArray - -Recursive array interface. - -## Generics - -- `TItem` -- `TRoot` - -## Definition - -- `RecursiveArray` diff --git a/website/src/routes/api/(types)/RecursiveArray/properties.ts b/website/src/routes/api/(types)/RecursiveArray/properties.ts deleted file mode 100644 index acce73463..000000000 --- a/website/src/routes/api/(types)/RecursiveArray/properties.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PropertyProps } from '~/components'; - -export const properties: Record = { - TItem: { - modifier: 'extends', - type: 'any', - }, - TRoot: { - modifier: 'extends', - type: 'any', - }, - Array: { - modifier: 'extends', - type: { - type: 'custom', - name: 'Array', - generics: [ - { - type: 'custom', - name: 'ResolveRecursiveValue', - generics: [ - { - type: 'custom', - name: 'TItem', - }, - { - type: 'custom', - name: 'TRoot', - }, - ], - }, - ], - }, - }, -}; diff --git a/website/src/routes/api/(types)/RecursiveMarker/index.mdx b/website/src/routes/api/(types)/RecursiveMarker/index.mdx deleted file mode 100644 index b221cee29..000000000 --- a/website/src/routes/api/(types)/RecursiveMarker/index.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: RecursiveMarker -description: Recursive marker type. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveMarker - -Recursive marker type. - -## Definition - -- `RecursiveMarker` diff --git a/website/src/routes/api/(types)/RecursiveMarker/properties.ts b/website/src/routes/api/(types)/RecursiveMarker/properties.ts deleted file mode 100644 index 1eabf0456..000000000 --- a/website/src/routes/api/(types)/RecursiveMarker/properties.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { PropertyProps } from '~/components'; - -export const properties: Record = { - RecursiveMarker: { - type: { - type: 'custom', - modifier: 'typeof', - name: 'RecursiveMarkerSymbol', - }, - }, -}; diff --git a/website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx b/website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx deleted file mode 100644 index 5cd428efc..000000000 --- a/website/src/routes/api/(types)/RecursiveMarkerSchema/index.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: RecursiveMarkerSchema -description: Recursive marker schema interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveMarkerSchema - -Recursive marker schema interface. - -## Definition - -- `RecursiveMarkerSchema` - - `type` diff --git a/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx b/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx deleted file mode 100644 index b99b32855..000000000 --- a/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/index.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: RecursiveMarkerSchemaAsync -description: Recursive marker schema async interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveMarkerSchemaAsync - -Recursive marker schema async interface. - -## Definition - -- `RecursiveMarkerSchemaAsync` - - `type` diff --git a/website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx b/website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx deleted file mode 100644 index 2ff821d0c..000000000 --- a/website/src/routes/api/(types)/RecursiveReadonlyArray/index.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: RecursiveReadonlyArray -description: Recursive readonly array interface. -contributors: - - yslpn ---- - -import { Property } from '~/components'; -import { properties } from './properties'; - -# RecursiveReadonlyArray - -Recursive readonly array interface. - -## Generics - -- `TItem` -- `TRoot` - -## Definition - -- `RecursiveReadonlyArray` diff --git a/website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts b/website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts deleted file mode 100644 index 10c6a2e5f..000000000 --- a/website/src/routes/api/(types)/RecursiveReadonlyArray/properties.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PropertyProps } from '~/components'; - -export const properties: Record = { - TItem: { - modifier: 'extends', - type: 'any', - }, - TRoot: { - modifier: 'extends', - type: 'any', - }, - ReadonlyArray: { - modifier: 'extends', - type: { - type: 'custom', - name: 'ReadonlyArray', - generics: [ - { - type: 'custom', - name: 'ResolveRecursiveValue', - generics: [ - { - type: 'custom', - name: 'TItem', - }, - { - type: 'custom', - name: 'TRoot', - }, - ], - }, - ], - }, - }, -}; diff --git a/website/src/routes/api/(types)/RecursiveSchema/index.mdx b/website/src/routes/api/(types)/RecursiveSchema/index.mdx index b6f5678b2..fabecf309 100644 --- a/website/src/routes/api/(types)/RecursiveSchema/index.mdx +++ b/website/src/routes/api/(types)/RecursiveSchema/index.mdx @@ -3,6 +3,7 @@ title: RecursiveSchema description: Recursive schema interface. contributors: - yslpn + - james-tindal --- import { Property } from '~/components'; @@ -15,6 +16,11 @@ Recursive schema interface. ## Generics - `TWrapped` +- `TInput` +- `TOutput` +- `TIssue` + +`TWrapped` defaults to `GenericSchema`. `TInput` and `TOutput` default to `unknown`. `TIssue` defaults to `BaseIssue`. ## Definition @@ -22,4 +28,4 @@ Recursive schema interface. - `type` - `reference` - `expects` - - `getter` + - `wrapped` diff --git a/website/src/routes/api/(types)/RecursiveSchema/properties.ts b/website/src/routes/api/(types)/RecursiveSchema/properties.ts index 3962127b6..de16c00f7 100644 --- a/website/src/routes/api/(types)/RecursiveSchema/properties.ts +++ b/website/src/routes/api/(types)/RecursiveSchema/properties.ts @@ -19,6 +19,21 @@ export const properties: Record = { ], }, }, + TInput: { + type: 'unknown', + }, + TOutput: { + type: 'unknown', + }, + TIssue: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + }, BaseSchema: { modifier: 'extends', type: { @@ -26,51 +41,9 @@ export const properties: Record = { name: 'BaseSchema', href: '../BaseSchema/', generics: [ - { - type: 'custom', - name: 'ResolveRecursiveInput', - generics: [ - { - type: 'custom', - name: 'InferInput', - href: '../InferInput/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, - ], - }, - { - type: 'custom', - name: 'ResolveRecursiveOutput', - generics: [ - { - type: 'custom', - name: 'InferOutput', - href: '../InferOutput/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, - ], - }, - { - type: 'custom', - name: 'InferIssue', - href: '../InferIssue/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, + { type: 'custom', name: 'TInput' }, + { type: 'custom', name: 'TOutput' }, + { type: 'custom', name: 'TIssue' }, ], }, }, @@ -86,6 +59,9 @@ export const properties: Record = { modifier: 'typeof', name: 'recursive', href: '../recursive/', + generics: [ + { type: 'custom', name: 'GenericSchema', href: '../GenericSchema/' }, + ], }, }, expects: { @@ -94,23 +70,7 @@ export const properties: Record = { value: 'unknown', }, }, - getter: { - type: { - type: 'function', - params: [ - { - name: 'self', - type: { - type: 'custom', - name: 'RecursiveMarkerSchema', - href: '../RecursiveMarkerSchema/', - }, - }, - ], - return: { - type: 'custom', - name: 'TWrapped', - }, - }, + wrapped: { + type: { type: 'custom', name: 'TWrapped' }, }, }; diff --git a/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx b/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx index 4d4a86326..4ce570bc7 100644 --- a/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx +++ b/website/src/routes/api/(types)/RecursiveSchemaAsync/index.mdx @@ -3,6 +3,7 @@ title: RecursiveSchemaAsync description: Recursive schema async interface. contributors: - yslpn + - james-tindal --- import { Property } from '~/components'; @@ -15,6 +16,11 @@ Recursive schema async interface. ## Generics - `TWrapped` +- `TInput` +- `TOutput` +- `TIssue` + +`TWrapped` defaults to `GenericSchema | GenericSchemaAsync`. `TInput` and `TOutput` default to `unknown`. `TIssue` defaults to `BaseIssue`. ## Definition @@ -22,4 +28,4 @@ Recursive schema async interface. - `type` - `reference` - `expects` - - `getter` + - `wrapped` diff --git a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts index a7a7bbfbe..9ce71b7c1 100644 --- a/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts +++ b/website/src/routes/api/(types)/RecursiveSchemaAsync/properties.ts @@ -39,6 +39,21 @@ export const properties: Record = { ], }, }, + TInput: { + type: 'unknown', + }, + TOutput: { + type: 'unknown', + }, + TIssue: { + modifier: 'extends', + type: { + type: 'custom', + name: 'BaseIssue', + href: '../BaseIssue/', + generics: ['unknown'], + }, + }, BaseSchemaAsync: { modifier: 'extends', type: { @@ -46,75 +61,39 @@ export const properties: Record = { name: 'BaseSchemaAsync', href: '../BaseSchemaAsync/', generics: [ - { - type: 'custom', - name: 'ResolveRecursiveInput', - generics: [ - { - type: 'custom', - name: 'InferInput', - href: '../InferInput/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, - ], - }, - { - type: 'custom', - name: 'ResolveRecursiveOutput', - generics: [ - { - type: 'custom', - name: 'InferOutput', - href: '../InferOutput/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, - ], - }, - { - type: 'custom', - name: 'InferIssue', - href: '../InferIssue/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, + { type: 'custom', name: 'TInput' }, + { type: 'custom', name: 'TOutput' }, + { type: 'custom', name: 'TIssue' }, ], }, }, type: { type: { type: 'string', - value: 'recursive', + value: 'recursiveAsync', }, }, reference: { type: { - type: 'union', - options: [ - { - type: 'custom', - modifier: 'typeof', - name: 'recursive', - href: '../recursive/', - }, + type: 'custom', + modifier: 'typeof', + name: 'recursiveAsync', + href: '../recursiveAsync/', + generics: [ { - type: 'custom', - modifier: 'typeof', - name: 'recursiveAsync', - href: '../recursiveAsync/', + type: 'union', + options: [ + { + type: 'custom', + name: 'GenericSchema', + href: '../GenericSchema/', + }, + { + type: 'custom', + name: 'GenericSchemaAsync', + href: '../GenericSchemaAsync/', + }, + ], }, ], }, @@ -125,30 +104,7 @@ export const properties: Record = { value: 'unknown', }, }, - getter: { - type: { - type: 'function', - params: [ - { - name: 'self', - type: { - type: 'custom', - name: 'RecursiveMarkerSchemaAsync', - href: '../RecursiveMarkerSchemaAsync/', - }, - }, - ], - return: { - type: 'custom', - name: 'MaybePromise', - href: '../MaybePromise/', - generics: [ - { - type: 'custom', - name: 'TWrapped', - }, - ], - }, - }, + wrapped: { + type: { type: 'custom', name: 'TWrapped' }, }, }; diff --git a/website/src/routes/api/(types)/Ref/index.mdx b/website/src/routes/api/(types)/Ref/index.mdx new file mode 100644 index 000000000..1236a5ffd --- /dev/null +++ b/website/src/routes/api/(types)/Ref/index.mdx @@ -0,0 +1,25 @@ +--- +title: Ref +description: Recursive reference interface. +contributors: + - yslpn + - james-tindal +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# Ref + +Recursive reference interface. + +## Generics + +- `TName` + +## Definition + +- `Ref` + - `type` + - `owner` + - `name` diff --git a/website/src/routes/api/(types)/RecursiveMarkerSchema/properties.ts b/website/src/routes/api/(types)/Ref/properties.ts similarity index 55% rename from website/src/routes/api/(types)/RecursiveMarkerSchema/properties.ts rename to website/src/routes/api/(types)/Ref/properties.ts index c4cad0b49..d9f5b39c1 100644 --- a/website/src/routes/api/(types)/RecursiveMarkerSchema/properties.ts +++ b/website/src/routes/api/(types)/Ref/properties.ts @@ -1,6 +1,10 @@ import type { PropertyProps } from '~/components'; export const properties: Record = { + TName: { + modifier: 'extends', + type: { type: 'union', options: ['string', 'undefined'] }, + }, BaseSchema: { modifier: 'extends', type: { @@ -11,21 +15,24 @@ export const properties: Record = { { type: 'custom', name: 'RecursiveMarker', - href: '../RecursiveMarker/', + generics: [{ type: 'custom', name: 'TName' }], }, { type: 'custom', name: 'RecursiveMarker', - href: '../RecursiveMarker/', + generics: [{ type: 'custom', name: 'TName' }], }, 'never', ], }, }, type: { - type: { - type: 'string', - value: 'recursive', - }, + type: { type: 'string', value: 'ref' }, + }, + owner: { + type: { type: 'custom', name: 'RecursiveOwner' }, + }, + name: { + type: { type: 'custom', name: 'TName' }, }, }; diff --git a/website/src/routes/api/(types)/RefAsync/index.mdx b/website/src/routes/api/(types)/RefAsync/index.mdx new file mode 100644 index 000000000..5f447fb90 --- /dev/null +++ b/website/src/routes/api/(types)/RefAsync/index.mdx @@ -0,0 +1,25 @@ +--- +title: RefAsync +description: Recursive reference async interface. +contributors: + - yslpn + - james-tindal +--- + +import { Property } from '~/components'; +import { properties } from './properties'; + +# RefAsync + +Recursive reference async interface. + +## Generics + +- `TName` + +## Definition + +- `RefAsync` + - `type` + - `owner` + - `name` diff --git a/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/properties.ts b/website/src/routes/api/(types)/RefAsync/properties.ts similarity index 55% rename from website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/properties.ts rename to website/src/routes/api/(types)/RefAsync/properties.ts index ad5e314c5..15be90ee8 100644 --- a/website/src/routes/api/(types)/RecursiveMarkerSchemaAsync/properties.ts +++ b/website/src/routes/api/(types)/RefAsync/properties.ts @@ -1,6 +1,10 @@ import type { PropertyProps } from '~/components'; export const properties: Record = { + TName: { + modifier: 'extends', + type: { type: 'union', options: ['string', 'undefined'] }, + }, BaseSchemaAsync: { modifier: 'extends', type: { @@ -11,21 +15,24 @@ export const properties: Record = { { type: 'custom', name: 'RecursiveMarker', - href: '../RecursiveMarker/', + generics: [{ type: 'custom', name: 'TName' }], }, { type: 'custom', name: 'RecursiveMarker', - href: '../RecursiveMarker/', + generics: [{ type: 'custom', name: 'TName' }], }, 'never', ], }, }, type: { - type: { - type: 'string', - value: 'recursive', - }, + type: { type: 'string', value: 'refAsync' }, + }, + owner: { + type: { type: 'custom', name: 'RecursiveOwner' }, + }, + name: { + type: { type: 'custom', name: 'TName' }, }, }; diff --git a/website/src/routes/api/menu.md b/website/src/routes/api/menu.md index 77a1d196a..337d78998 100644 --- a/website/src/routes/api/menu.md +++ b/website/src/routes/api/menu.md @@ -20,6 +20,7 @@ - [looseObject](/api/looseObject/) - [looseTuple](/api/looseTuple/) - [map](/api/map/) +- [mutualRecursive](/api/mutualRecursive/) - [nan](/api/nan/) - [never](/api/never/) - [nonNullable](/api/nonNullable/) @@ -256,6 +257,7 @@ - [looseObjectAsync](/api/looseObjectAsync/) - [looseTupleAsync](/api/looseTupleAsync/) - [mapAsync](/api/mapAsync/) +- [mutualRecursiveAsync](/api/mutualRecursiveAsync/) - [nonNullableAsync](/api/nonNullableAsync/) - [nonNullishAsync](/api/nonNullishAsync/) - [nonOptionalAsync](/api/nonOptionalAsync/) @@ -401,7 +403,6 @@ - [GenericPipeActionAsync](/api/GenericPipeActionAsync/) - [GenericPipeItem](/api/GenericPipeItem/) - [GenericPipeItemAsync](/api/GenericPipeItemAsync/) -- [GenericRecursiveSchema](/api/GenericRecursiveSchema/) - [GenericSchema](/api/GenericSchema/) - [GenericSchemaAsync](/api/GenericSchemaAsync/) - [GenericTransformation](/api/GenericTransformation/) @@ -570,6 +571,8 @@ - [MinWordsIssue](/api/MinWordsIssue/) - [MultipleOfAction](/api/MultipleOfAction/) - [MultipleOfIssue](/api/MultipleOfIssue/) +- [MutualRecursiveSchema](/api/MutualRecursiveSchema/) +- [MutualRecursiveSchemaAsync](/api/MutualRecursiveSchemaAsync/) - [NanIssue](/api/NanIssue/) - [NanSchema](/api/NanSchema/) - [NeverIssue](/api/NeverIssue/) @@ -670,14 +673,11 @@ - [RecordIssue](/api/RecordIssue/) - [RecordSchema](/api/RecordSchema/) - [RecordSchemaAsync](/api/RecordSchemaAsync/) -- [RecursiveArray](/api/RecursiveArray/) -- [RecursiveMarker](/api/RecursiveMarker/) -- [RecursiveMarkerSchema](/api/RecursiveMarkerSchema/) -- [RecursiveMarkerSchemaAsync](/api/RecursiveMarkerSchemaAsync/) -- [RecursiveReadonlyArray](/api/RecursiveReadonlyArray/) - [RecursiveSchema](/api/RecursiveSchema/) - [RecursiveSchemaAsync](/api/RecursiveSchemaAsync/) - [ReduceItemsAction](/api/ReduceItemsAction/) +- [Ref](/api/Ref/) +- [RefAsync](/api/RefAsync/) - [Reference](/api/Reference/) - [RegexAction](/api/RegexAction/) - [RegexIssue](/api/RegexIssue/) diff --git a/website/src/routes/guides/(main-concepts)/schemas/index.mdx b/website/src/routes/guides/(main-concepts)/schemas/index.mdx index 02d6470a4..b6fb9d65d 100644 --- a/website/src/routes/guides/(main-concepts)/schemas/index.mdx +++ b/website/src/routes/guides/(main-concepts)/schemas/index.mdx @@ -11,6 +11,7 @@ contributors: - yicrotkd - gotnoklu - yslpn + - james-tindal --- import { ApiList, Link } from '~/components'; @@ -114,6 +115,7 @@ Beyond primitive and complex values, there are also schema functions for more sp 'intersect', 'lazy', 'literal', + 'mutualRecursive', 'nan', 'never', 'nonNullable', @@ -143,6 +145,10 @@ const InstanceSchema = v.instance(Error); // Error const LazySchema = v.lazy(() => v.string()); // string const IntersectSchema = v.intersect([v.string(), v.literal('a')]); // string & 'a' const LiteralSchema = v.literal('foo'); // 'foo' +const MutualRecursiveSchemas = v.mutualRecursive((ref) => ({ + a: v.object({ b: v.optional(ref('b')) }), + b: v.object({ a: v.optional(ref('a')) }), +})); // { a: ..., b: ... } const NanSchema = v.nan(); // NaN const NeverSchema = v.never(); // never const NonNullableSchema = v.nonNullable(v.nullable(v.string())); // string diff --git a/website/src/routes/guides/(schemas)/other/index.mdx b/website/src/routes/guides/(schemas)/other/index.mdx index c9ba5e51f..0c9727429 100644 --- a/website/src/routes/guides/(schemas)/other/index.mdx +++ b/website/src/routes/guides/(schemas)/other/index.mdx @@ -2,18 +2,19 @@ title: Other description: >- This guide explains other special schema functions such as `literal`, - `instance`, `custom`, `recursive` and `lazy` that are not covered in the + `instance`, `custom`, `recursive` and `mutualRecursive` that are not covered in the other guides. contributors: - fabian-hiller - yslpn + - james-tindal --- import { Link } from '~/components'; # Other -This guide explains other special schema functions such as `literal`, `instance`, `custom`, `recursive` and `lazy` that are not covered in the other guides. +This guide explains other special schema functions such as `literal`, `instance`, `custom`, `recursive` and `mutualRecursive` that are not covered in the other guides. ## Literal schema @@ -86,18 +87,21 @@ const JsonSchema = v.recursive((self) => ); ``` -## Lazy schema +### Mutual recursion -The `lazy` schema function calls a getter when parsing an input value. Use it when you need to choose a schema based on the input value. - -> For ordinary recursive schemas, `recursive` is usually a better choice because it can infer input and output types automatically. +With `mutualRecursive` you can define schemas that reference each other. The `ref` argument takes the key of another entry and returns a reference to it. ```ts import * as v from 'valibot'; -const EmailOrUrlSchema = v.lazy((input) => - typeof input === 'string' && input.startsWith('https://') - ? v.pipe(v.string(), v.url()) - : v.pipe(v.string(), v.email()) -); +const { category, product } = v.mutualRecursive((ref) => ({ + category: v.object({ + name: v.string(), + products: v.array(ref('product')), + }), + product: v.object({ + name: v.string(), + category: v.optional(ref('category')), + }), +})); ``` From 6843c1bc9bc0025a5cfdb876cd3bd2d796bd05f1 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 03:28:45 +0100 Subject: [PATCH 14/39] cubic 1: fix: traverse all keys when checking recursive references --- library/src/schemas/recursive/_recursive.ts | 59 +++---- .../recursive/recursive.references.test.ts | 150 ++++++++++++++++++ .../src/schemas/recursive/recursive.test.ts | 2 + .../schemas/recursive/recursiveAsync.test.ts | 2 + 4 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 library/src/schemas/recursive/recursive.references.test.ts diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index d59bf9e49..922a91720 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -9,6 +9,10 @@ import type { UnknownDataset, } from '../../types/index.ts'; import { _standardSchema } from '../../utils/_standardSchema/index.ts'; +import type { MutualRecursiveSchema } from '../mutualRecursive/mutualRecursive.ts'; +import type { MutualRecursiveSchemaAsync } from '../mutualRecursive/mutualRecursiveAsync.ts'; +import type { RecursiveSchema } from './recursive.ts'; +import type { RecursiveSchemaAsync } from './recursiveAsync.ts'; // Named interfaces keep recursive array output from collapsing into circular // type aliases while preserving normal array behavior. @@ -264,6 +268,19 @@ export type RecursiveReferences = */ export const OwnerSymbol: unique symbol = Symbol('recursive owner'); +/** + * Recursive schema brand. + * + * @internal + */ +const RecursiveSchemaSymbol: unique symbol = Symbol('recursive schema'); + +type AnyRecursiveSchema = + | RecursiveSchema + | RecursiveSchemaAsync + | MutualRecursiveSchema + | MutualRecursiveSchemaAsync; + /** * Configuration that carries the recursive owner. * @@ -293,7 +310,7 @@ export interface RecursiveOwner { /** * The owner schema factory. */ - readonly reference: SyncOrAsyncSchema['reference']; + readonly reference: AnyRecursiveSchema['reference']; } /** @@ -316,13 +333,13 @@ export function createRecursiveSchema< wrapped: TSchema['wrapped'], owner: RecursiveOwner & { readonly async: TSchema['async'] }, type: TSchema['type'], - reference: SyncOrAsyncSchema['reference'] + reference: AnyRecursiveSchema['reference'] ): TSchema; export function createRecursiveSchema( wrapped: SyncOrAsyncSchema, owner: RecursiveOwner, - type: string, - reference: SyncOrAsyncSchema['reference'] + type: AnyRecursiveSchema['type'], + reference: AnyRecursiveSchema['reference'] ): unknown { const withOwner = (config: ConfigWithOwner): ConfigWithOwner => ({ ...config, @@ -331,9 +348,8 @@ export function createRecursiveSchema( const run = (dataset: UnknownDataset, config: ConfigWithOwner) => wrapped['~run'](dataset, withOwner(config)); - return _standardSchema< - SyncOrAsyncSchema & { readonly wrapped: SyncOrAsyncSchema } - >({ + return _standardSchema({ + [RecursiveSchemaSymbol]: true, kind: 'schema', type, reference, @@ -362,19 +378,19 @@ export function checkRecursiveReferences( ): void { if ( !value || - !['object', 'function'].includes(typeof value) || + (typeof value !== 'object' && typeof value !== 'function') || seen.has(value) ) { return; } seen.add(value); - const schema = value as Record; - if (['schema', 'transformation'].includes(schema.kind as string)) { - if (schema.owner !== undefined && schema.owner !== owner) { + const object = value as Record; + if (['schema', 'transformation'].includes(object.kind as string)) { + if (object.owner !== undefined && object.owner !== owner) { throw Error('Recursive reference used outside its recursive schema'); } - if (schema.owner === owner) { - const name = schema.name as string | undefined; + if (object.owner === owner) { + const name = object.name as string | undefined; if (name === undefined) { if (owner.definition.kind !== 'schema') { throw Error( @@ -389,20 +405,9 @@ export function checkRecursiveReferences( } } } - for (const key in schema) { - if ( - !['owner', 'reference', '~standard', '~types'].includes(key) && - !( - key === 'wrapped' && - [ - 'recursive', - 'recursiveAsync', - 'mutualRecursive', - 'mutualRecursiveAsync', - ].includes(schema.type as string) - ) - ) { - checkRecursiveReferences(schema[key], owner, seen); + for (const key in object) { + if (key !== 'wrapped' || !(RecursiveSchemaSymbol in object)) { + checkRecursiveReferences(object[key], owner, seen); } } } diff --git a/library/src/schemas/recursive/recursive.references.test.ts b/library/src/schemas/recursive/recursive.references.test.ts new file mode 100644 index 000000000..752c58f5b --- /dev/null +++ b/library/src/schemas/recursive/recursive.references.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'vitest'; +import { + any, + array, + arrayAsync, + config, + description, + mutualRecursive, + mutualRecursiveAsync, + object, + objectAsync, + parse, + parseAsync, + pipe, + pipeAsync, + recursive, + recursiveAsync, + returns, +} from '../../index.ts'; + +const keys = ['normal', 'owner', 'reference', '~standard', '~types', 'wrapped']; +const foreignMessage = 'Recursive reference used outside its recursive schema'; + +describe('recursive reference traversal', () => { + test('composes direct, piped, and configured sync recursive schemas', () => { + const inners = [ + recursive((self) => object({ children: array(self) })), + mutualRecursive((ref) => ({ + node: object({ children: array(ref('node')) }), + })).node, + ]; + const input = { inner: { children: [{ children: [] }] } }; + for (const inner of inners) { + for (const schema of [ + inner, + pipe(inner, description('inner')), + config(inner, { abortEarly: true }), + ]) { + expect( + parse( + recursive(() => object({ inner: schema })), + input + ) + ).toStrictEqual(input); + expect( + parse( + mutualRecursive(() => ({ root: object({ inner: schema }) })).root, + input + ) + ).toStrictEqual(input); + } + } + }); + + test('composes direct, piped, and configured recursive schemas in async roots', async () => { + const inners = [ + recursive((self) => object({ children: array(self) })), + mutualRecursive((ref) => ({ + node: object({ children: array(ref('node')) }), + })).node, + recursiveAsync((self) => objectAsync({ children: arrayAsync(self) })), + mutualRecursiveAsync((ref) => ({ + node: objectAsync({ children: arrayAsync(ref('node')) }), + })).node, + ]; + const input = { inner: { children: [{ children: [] }] } }; + for (const inner of inners) { + for (const schema of [ + inner, + pipeAsync(inner, description('inner')), + config(inner, { abortEarly: true }), + ]) { + await expect( + parseAsync( + recursiveAsync(() => objectAsync({ inner: schema })), + input + ) + ).resolves.toStrictEqual(input); + await expect( + parseAsync( + mutualRecursiveAsync(() => ({ + root: objectAsync({ inner: schema }), + })).root, + input + ) + ).resolves.toStrictEqual(input); + } + } + }); + + test.each(keys)( + 'rejects foreign references under the entry name %s', + (key) => { + const foreign = mutualRecursive((ref) => ({ + root: object({ children: array(ref('root')) }), + })).root.wrapped.entries.children.item; + expect(() => recursive(() => object({ [key]: foreign }))).toThrowError( + foreignMessage + ); + expect(() => + recursiveAsync(() => objectAsync({ [key]: foreign })) + ).toThrowError(foreignMessage); + expect(() => + mutualRecursive(() => ({ root: object({ [key]: foreign }) })) + ).toThrowError(foreignMessage); + expect(() => + mutualRecursiveAsync(() => ({ root: objectAsync({ [key]: foreign }) })) + ).toThrowError(foreignMessage); + } + ); + + test.each(keys)( + 'rejects unmatched references under the entry name %s', + (key) => { + expect(() => { + // @ts-expect-error Deliberately exercise the runtime unmatched-reference check. + return mutualRecursive((ref) => ({ + root: object({ [key]: ref('missing') }), + })); + }).toThrowError("Unmatched recursive reference: 'missing'"); + expect(() => { + // @ts-expect-error Deliberately exercise the runtime unmatched-reference check. + return mutualRecursiveAsync((ref) => ({ + root: objectAsync({ [key]: ref('missing') }), + })); + }).toThrowError("Unmatched recursive reference: 'missing'"); + } + ); + + test('rejects foreign references in actions appended to recursive schemas', () => { + const foreign = recursive((self) => object({ children: array(self) })) + .wrapped.entries.children.item; + const inner = recursive(() => any()); + const innerAsync = recursiveAsync(() => any()); + expect(() => recursive(() => pipe(inner, returns(foreign)))).toThrowError( + foreignMessage + ); + expect(() => + mutualRecursive(() => ({ root: pipe(inner, returns(foreign)) })) + ).toThrowError(foreignMessage); + expect(() => + recursiveAsync(() => pipeAsync(innerAsync, returns(foreign))) + ).toThrowError(foreignMessage); + expect(() => + mutualRecursiveAsync(() => ({ + root: pipeAsync(innerAsync, returns(foreign)), + })) + ).toThrowError(foreignMessage); + }); +}); diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index 3a2e10277..563041d84 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -21,7 +21,9 @@ describe('recursive', () => { const schema = recursive(definition); expect(schema.wrapped).toBe(definition.mock.results[0].value); expect(definition).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertySymbols(schema)).toHaveLength(1); expect(schema).toStrictEqual({ + [Object.getOwnPropertySymbols(schema)[0]]: true, kind: 'schema', type: 'recursive', reference: recursive, diff --git a/library/src/schemas/recursive/recursiveAsync.test.ts b/library/src/schemas/recursive/recursiveAsync.test.ts index 725b3cfc9..629a952b7 100644 --- a/library/src/schemas/recursive/recursiveAsync.test.ts +++ b/library/src/schemas/recursive/recursiveAsync.test.ts @@ -20,7 +20,9 @@ describe('recursiveAsync', () => { const schema = recursiveAsync(definition); expect(schema.wrapped).toBe(definition.mock.results[0].value); expect(definition).toHaveBeenCalledTimes(1); + expect(Object.getOwnPropertySymbols(schema)).toHaveLength(1); expect(schema).toStrictEqual({ + [Object.getOwnPropertySymbols(schema)[0]]: true, kind: 'schema', type: 'recursiveAsync', reference: recursiveAsync, From d5ca1715dd5c5f6ae43ba0f2d8a486a4c54b1fcc Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 03:37:04 +0100 Subject: [PATCH 15/39] cubic 3: fix: preserve empty recursive definition names --- .../converters/convertSchema/convertSchema.ts | 4 ++-- .../toJsonSchema/toJsonSchema.test.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index d281e762c..b09abd372 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -164,7 +164,7 @@ export function convertSchema( valibotSchema.type === 'mutualRecursive') ? context.referenceMap.get(valibotSchema.wrapped) : undefined); - if (referenceId) { + if (referenceId !== undefined) { jsonSchema.$ref = getDefinitionRef(referenceId); if (config?.overrideRef) { const refOverride = config.overrideRef({ @@ -632,7 +632,7 @@ export function convertSchema( context.referenceMap.get(wrapped); // Add recursive Valibot schema to reference map, if necessary - if (!referenceId) { + if (referenceId === undefined) { referenceId = context.referenceMap.createId(context.definitions); context.referenceMap.set(recursiveSchema, referenceId); } diff --git a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts index f35cd65be..35943ff92 100644 --- a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts +++ b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts @@ -393,6 +393,28 @@ describe('toJsonSchema', () => { }); }); + test('preserves an empty recursive definition name', () => { + const schema = v.recursive((self) => + v.object({ children: v.array(self) }) + ); + + expect( + toJsonSchema(schema, { definitions: { '': schema } }) + ).toStrictEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/$defs/', + $defs: { + '': { + type: 'object', + properties: { + children: { type: 'array', items: { $ref: '#/$defs/' } }, + }, + required: ['children'], + }, + }, + }); + }); + test('for recursive definitions with JSON Pointer special characters', () => { const category = v.recursive((self) => v.object({ From 6df0f60dbc1865fd3fa91f71891db5219f041e6a Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 03:42:14 +0100 Subject: [PATCH 16/39] cubic 4: fix: reuse named definitions for mutual recursive references --- .../toJsonSchema/toJsonSchema.test.ts | 30 +++++++++++++++++++ .../functions/toJsonSchema/toJsonSchema.ts | 12 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts index 35943ff92..e03292cec 100644 --- a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts +++ b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.test.ts @@ -415,6 +415,36 @@ describe('toJsonSchema', () => { }); }); + test.each([false, true])( + 'reuses named mutual definitions (reverse order: %s)', + (reverse) => { + const schemas = v.mutualRecursive((ref) => ({ + a: v.object({ next: v.optional(ref('b')) }), + b: v.object({ next: v.optional(ref('a')) }), + })); + const definitions = reverse + ? { b: schemas.b, a: schemas.a } + : { a: schemas.a, b: schemas.b }; + + expect(toJsonSchema(schemas.a, { definitions })).toStrictEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/$defs/a', + $defs: { + a: { + type: 'object', + properties: { next: { $ref: '#/$defs/b' } }, + required: [], + }, + b: { + type: 'object', + properties: { next: { $ref: '#/$defs/a' } }, + required: [], + }, + }, + }); + } + ); + test('for recursive definitions with JSON Pointer special characters', () => { const category = v.recursive((self) => v.object({ diff --git a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts index 5f987701f..63bedd014 100644 --- a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts +++ b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts @@ -33,7 +33,17 @@ export function toJsonSchema( // Add provided definitions to context, if necessary if (definitions) { for (const key in definitions) { - context.referenceMap.set(definitions[key], key); + const definition = definitions[key]; + context.referenceMap.set(definition, key); + if ( + definition.type === 'recursive' || + definition.type === 'mutualRecursive' + ) { + context.referenceMap.set( + (definition as v.RecursiveSchema | v.MutualRecursiveSchema).wrapped, + key + ); + } } for (const key in definitions) { context.definitions[key] = convertSchema( From f1b577b9d93eef449b024fa87c43f12404644b2b Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 13:32:57 +0100 Subject: [PATCH 17/39] cubic 6: test: assert recursive parsing preserves configuration values --- .../mutualRecursive/mutualRecursiveAsync.execution.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts index ceb93edb7..a3440a97e 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.execution.test.ts @@ -83,6 +83,7 @@ describe('mutualRecursiveAsync execution', () => { ); await expect(parseAsync(marker, input, config)).rejects.toThrow(outside); expect(Reflect.ownKeys(config)).toStrictEqual(['abortEarly']); + expect(config).toStrictEqual({ abortEarly: true }); }); test('rejects foreign references in schemas and async return actions', () => { From 19f948bcfc7997d8479957572502e7094746f510 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 13:35:58 +0100 Subject: [PATCH 18/39] cubic 5: docs: list mutualRecursive JSON Schema support --- packages/to-json-schema/README.md | 65 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/to-json-schema/README.md b/packages/to-json-schema/README.md index e2f7081f8..17a7a95ef 100644 --- a/packages/to-json-schema/README.md +++ b/packages/to-json-schema/README.md @@ -22,38 +22,39 @@ This package is particularly popular for: **Note**: Converted schemas may behave slightly differently in JSON schema validators (especially for string format) because their implementation is different from Valibot's. -| Schema | Status | Note | -| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| `any` | ✅ | | -| `array` | ✅ | | -| `boolean` | ✅ | | -| `enum` | ⚠️ | Only JSON compatible values are supported | -| `exactOptional` | ✅ | | -| `intersect` | ✅ | | -| `lazy` | ⚠️ | The `.getter` function is always executed with `undefined` as input | -| `literal` | ⚠️ | Only JSON compatible values are supported | -| `looseObject` | ✅ | | -| `looseTuple` | ✅ | | -| `never` | ✅ | | -| `null` | ✅ | | -| `nullable` | ✅ | | -| `nullish` | ✅ | | -| `number` | ✅ | | -| `objectWithRest` | ✅ | | -| `object` | ✅ | | -| `optional` | ✅ | | -| `picklist` | ⚠️ | Only JSON compatible values are supported | -| `record` | ⚠️ | Only `string` schemas for the key of the record are supported. Adds `propertyNames` for key validation (not available in OpenAPI 3.0) | -| `recursive` | ✅ | Missing definitions will be generated automatically | -| `strictObject` | ✅ | | -| `strictTuple` | ✅ | | -| `string` | ✅ | | -| `tupleWithRest` | ✅ | | -| `tuple` | ✅ | | -| `union` | ✅ | | -| `undefinedable` | ✅ | | -| `unknown` | ✅ | | -| `variant` | ⚠️ | The discriminator key will be ignored | +| Schema | Status | Note | +| ----------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `any` | ✅ | | +| `array` | ✅ | | +| `boolean` | ✅ | | +| `enum` | ⚠️ | Only JSON compatible values are supported | +| `exactOptional` | ✅ | | +| `intersect` | ✅ | | +| `lazy` | ⚠️ | The `.getter` function is always executed with `undefined` as input | +| `literal` | ⚠️ | Only JSON compatible values are supported | +| `looseObject` | ✅ | | +| `looseTuple` | ✅ | | +| `mutualRecursive` | ✅ | Missing definitions will be generated automatically | +| `never` | ✅ | | +| `null` | ✅ | | +| `nullable` | ✅ | | +| `nullish` | ✅ | | +| `number` | ✅ | | +| `objectWithRest` | ✅ | | +| `object` | ✅ | | +| `optional` | ✅ | | +| `picklist` | ⚠️ | Only JSON compatible values are supported | +| `record` | ⚠️ | Only `string` schemas for the key of the record are supported. Adds `propertyNames` for key validation (not available in OpenAPI 3.0) | +| `recursive` | ✅ | Missing definitions will be generated automatically | +| `strictObject` | ✅ | | +| `strictTuple` | ✅ | | +| `string` | ✅ | | +| `tupleWithRest` | ✅ | | +| `tuple` | ✅ | | +| `union` | ✅ | | +| `undefinedable` | ✅ | | +| `unknown` | ✅ | | +| `variant` | ⚠️ | The discriminator key will be ignored | | Actions | Status | Note | | --------------- | ------ | ------------------------------------------------------ | From 0d8247bccd04ff6341002c84508413ec5f790080 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 13:38:30 +0100 Subject: [PATCH 19/39] cubic 7: docs: link recursive from recursiveAsync --- website/src/routes/api/(async)/recursiveAsync/index.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/website/src/routes/api/(async)/recursiveAsync/index.mdx b/website/src/routes/api/(async)/recursiveAsync/index.mdx index 8b2431e6e..83fa57ba1 100644 --- a/website/src/routes/api/(async)/recursiveAsync/index.mdx +++ b/website/src/routes/api/(async)/recursiveAsync/index.mdx @@ -70,6 +70,7 @@ The following APIs can be combined with `recursiveAsync`. 'object', 'optional', 'record', + 'recursive', 'union', ]} /> From 0508cd08a94e083610041d32448de6f890f038cb Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 15:14:15 +0100 Subject: [PATCH 20/39] cubic 2: feat: support registered generics in recursive types --- library/src/index.ts | 2 + library/src/schemas/recursive/_recursive.ts | 84 ++-- .../recursive/recursive.generics.test-d.ts | 183 +++++++++ library/src/types/generics.ts | 28 ++ library/src/types/index.ts | 1 + website/src/hooks/useMDXComponents.tsx | 2 +- .../(async)/mutualRecursiveAsync/index.mdx | 47 +++ .../api/(async)/recursiveAsync/index.mdx | 47 +++ .../api/(schemas)/mutualRecursive/index.mdx | 47 +++ .../routes/api/(schemas)/recursive/index.mdx | 47 +++ .../(types)/Generics/GenericsGenerator.tsx | 373 ++++++++++++++++++ .../(types)/Generics/generateGeneric.test.ts | 126 ++++++ .../api/(types)/Generics/generateGeneric.ts | 42 ++ .../routes/api/(types)/Generics/generics.css | 77 ++++ .../src/routes/api/(types)/Generics/index.mdx | 45 +++ .../routes/api/(types)/Generics/properties.ts | 15 + website/src/routes/api/menu.md | 1 + website/src/styles/root.css | 5 +- 18 files changed, 1148 insertions(+), 24 deletions(-) create mode 100644 library/src/schemas/recursive/recursive.generics.test-d.ts create mode 100644 library/src/types/generics.ts create mode 100644 website/src/routes/api/(types)/Generics/GenericsGenerator.tsx create mode 100644 website/src/routes/api/(types)/Generics/generateGeneric.test.ts create mode 100644 website/src/routes/api/(types)/Generics/generateGeneric.ts create mode 100644 website/src/routes/api/(types)/Generics/generics.css create mode 100644 website/src/routes/api/(types)/Generics/index.mdx create mode 100644 website/src/routes/api/(types)/Generics/properties.ts diff --git a/library/src/index.ts b/library/src/index.ts index 866a8ec44..27154217b 100644 --- a/library/src/index.ts +++ b/library/src/index.ts @@ -19,6 +19,8 @@ export type { DefaultValue, ErrorMessage, FailureDataset, + GenericConstraintError, + Generics, GenericIssue, GenericMetadata, GenericSchema, diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 922a91720..c3b5286aa 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -3,6 +3,8 @@ import type { BaseSchema, BaseSchemaAsync, Config, + GenericConstraintError, + Generics, GenericSchema, GenericSchemaAsync, IsAny, @@ -150,12 +152,42 @@ type ResolveRecursiveArray< : never : ResolveRecursiveFixedTuple; -// Rebuilds a container with its type arguments resolved. TypeScript cannot -// reconstruct an arbitrary generic with substituted type arguments, so each -// container kind must be enumerated explicitly. This list must cover every -// container a Valibot schema can infer; an unlisted generic (e.g. `WeakMap`) -// falls through to the object branch, which maps over its methods and leaves -// the marker in its type arguments unresolved. +// Extract arguments and names of matching registered generics +type RegisteredGeneric = { + [TName in keyof Generics]: Generics< + TValue, + never + >[TName] extends { + readonly getArgs: infer TArgs extends readonly unknown[]; + } + ? [TArgs] extends [never] + ? never + : { readonly name: TName; readonly args: TArgs } + : never; +}[keyof Generics]; + +// Reconstruct registered generics with resolved arguments +type ResolveRegisteredGeneric< + TValue, + TRoot, + TGeneric = RegisteredGeneric, +> = TGeneric extends { + readonly name: infer TName extends keyof Generics; + readonly args: infer TArgs extends readonly unknown[]; +} + ? Generics>[TName] extends { + readonly applyArgs: infer TOutput; + } + ? [TOutput] extends [never] + ? GenericConstraintError<{ + generic: TName; + received: ResolveRecursiveTuple; + }> + : TOutput + : never + : never; + +// Preserve built-in container handling before registered generics and objects type ResolveRecursiveContainer = TValue extends Map ? Map< @@ -175,14 +207,16 @@ type ResolveRecursiveContainer = ? Promise> : TValue extends readonly unknown[] ? ResolveRecursiveArray - : TValue extends object - ? { - [TKey in keyof TValue]: ResolveRecursiveValue< - TValue[TKey], - TRoot - >; - } - : TValue; + : [RegisteredGeneric] extends [never] + ? TValue extends object + ? { + [TKey in keyof TValue]: ResolveRecursiveValue< + TValue[TKey], + TRoot + >; + } + : TValue + : ResolveRegisteredGeneric; type MergeReference = { [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; @@ -252,14 +286,20 @@ export type RecursiveReferences = ? RecursiveReferences : TValue extends readonly (infer TItem)[] ? RecursiveReferences - : TValue extends object - ? { - [TKey in keyof TValue]-?: RecursiveReferences< - TValue[TKey], - TSeen | TValue - >; - }[keyof TValue] - : never; + : [RegisteredGeneric] extends [never] + ? TValue extends object + ? { + [TKey in keyof TValue]-?: RecursiveReferences< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] + : never + : RegisteredGeneric extends { + readonly args: infer TArgs extends readonly unknown[]; + } + ? RecursiveReferences + : never; /** * The recursive owner currently running. diff --git a/library/src/schemas/recursive/recursive.generics.test-d.ts b/library/src/schemas/recursive/recursive.generics.test-d.ts new file mode 100644 index 000000000..332bdf939 --- /dev/null +++ b/library/src/schemas/recursive/recursive.generics.test-d.ts @@ -0,0 +1,183 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import * as v from '../../index.ts'; + +interface Box { + readonly kind: 'box'; + readonly value: T; +} + +declare module '../../types/generics.ts' { + interface Generics { + WeakMap: { + getArgs: T extends WeakMap ? [K, V] : never; + applyArgs: TArgs extends [infer K extends object, infer V] + ? WeakMap + : never; + }; + Box: { + getArgs: T extends Box ? [V] : never; + applyArgs: TArgs extends [infer V] ? Box : never; + }; + } +} + +interface InputNode { + name: string; + children: InputNode[]; +} + +interface OutputNode { + name: string; + children: WeakMap; +} + +describe('registered recursive generics', () => { + test('extracts and applies generic arguments', () => { + expectTypeOf< + v.Generics, never>['WeakMap']['getArgs'] + >().toEqualTypeOf<[object, string]>(); + expectTypeOf['WeakMap']['getArgs']>().toBeNever(); + expectTypeOf< + v.Generics['WeakMap']['applyArgs'] + >().toEqualTypeOf>(); + expectTypeOf< + v.Generics['WeakMap']['applyArgs'] + >().toBeNever(); + }); + + test('resolves transformed recursive values inside WeakMap', () => { + const schema = v.recursive((self) => + v.pipe( + v.object({ name: v.string(), children: v.array(self) }), + v.transform((node) => ({ + name: node.name, + children: new WeakMap( + node.children.map((child) => [child, child]) + ), + })) + ) + ); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(schema) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf(); + expectTypeOf['children']>().toEqualTypeOf< + WeakMap + >(); + }); + + test('resolves transformed async recursive values inside WeakMap', () => { + const schema = v.recursiveAsync((self) => + v.pipeAsync( + v.objectAsync({ name: v.string(), children: v.arrayAsync(self) }), + v.transform((node) => ({ + name: node.name, + children: new WeakMap( + node.children.map((child) => [child, child]) + ), + })) + ) + ); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(schema) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf(); + }); + + test('resolves mutually recursive values in registered generics', () => { + const schemas = v.mutualRecursive((ref) => ({ + node: v.pipe( + v.object({ leaf: ref('leaf') }), + v.transform( + (node) => new WeakMap([[{}, node.leaf]]) + ) + ), + leaf: v.object({ parent: v.optional(ref('node')) }), + })); + interface Leaf { + parent?: WeakMap | undefined; + } + expectTypeOf(schemas.node) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf>(); + }); + + test('resolves mutually recursive async values in registered generics', () => { + const schemas = v.mutualRecursiveAsync((ref) => ({ + node: v.pipeAsync( + v.objectAsync({ leaf: ref('leaf') }), + v.transform( + (node) => new WeakMap([[{}, node.leaf]]) + ) + ), + leaf: v.objectAsync({ parent: v.optionalAsync(ref('node')) }), + })); + interface Leaf { + parent?: WeakMap | undefined; + } + expectTypeOf(schemas.node) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf>(); + }); + + test('reports constraint errors after resolving named references', () => { + const schemas = v.mutualRecursive((ref) => ({ + key: v.string(), + lookup: v.pipe( + v.object({ key: ref('key') }), + v.transform((node) => new WeakMap([[node.key, true]])) + ), + })); + expectTypeOf(schemas.lookup) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf< + v.GenericConstraintError<{ + generic: 'WeakMap'; + received: [string, boolean]; + }> + >(); + }); + + test('resolves user-defined generics inside built-in containers', () => { + const schema = v.recursive((self) => + v.pipe( + v.object({ name: v.string(), children: v.array(self) }), + v.transform((node) => ({ + name: node.name, + children: node.children.map((child) => ({ + kind: 'box' as const, + value: child, + })), + })) + ) + ); + interface BoxNode { + name: string; + children: Box[]; + } + expectTypeOf(schema) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf(); + }); + + test('preserves registered types without recursive references', () => { + const schema = v.recursive(() => v.instance(WeakMap)); + expectTypeOf(schema) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf>(); + }); +}); diff --git a/library/src/types/generics.ts b/library/src/types/generics.ts new file mode 100644 index 000000000..9c3515a89 --- /dev/null +++ b/library/src/types/generics.ts @@ -0,0 +1,28 @@ +/** + * Generics registry interface. + * + * Each entry provides `getArgs` to extract the arguments of `T`, or `never` + * when it does not match, and `applyArgs` to reconstruct it from `TArgs`. + * Extend this interface through module augmentation to register a generic. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-unused-vars -- Type parameters are used by module augmentations. +export interface Generics {} + +/** + * Generic constraint error interface. + */ +export interface GenericConstraintError< + TError extends { + readonly generic: PropertyKey; + readonly received: unknown; + }, +> { + /** + * The generic name. + */ + readonly generic: TError['generic']; + /** + * The resolved arguments. + */ + readonly received: TError['received']; +} diff --git a/library/src/types/index.ts b/library/src/types/index.ts index dd6ebf294..38bcd57a3 100644 --- a/library/src/types/index.ts +++ b/library/src/types/index.ts @@ -1,5 +1,6 @@ export * from './config.ts'; export * from './dataset.ts'; +export * from './generics.ts'; export * from './infer.ts'; export * from './issue.ts'; export * from './metadata.ts'; diff --git a/website/src/hooks/useMDXComponents.tsx b/website/src/hooks/useMDXComponents.tsx index 3bb567988..e58db2a15 100644 --- a/website/src/hooks/useMDXComponents.tsx +++ b/website/src/hooks/useMDXComponents.tsx @@ -14,7 +14,7 @@ import { trackEvent } from '~/utils'; /** * Pre component for rendering code snippets. */ -const Pre = component$(() => { +export const Pre = component$(() => { // Use element and state signals const preElement = useSignal(); const isValibotCode = useSignal(false); diff --git a/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx b/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx index c89ec9775..bb2a68fb2 100644 --- a/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx +++ b/website/src/routes/api/(async)/mutualRecursiveAsync/index.mdx @@ -57,6 +57,49 @@ const { category, product } = v.mutualRecursiveAsync((ref) => ({ })); ``` +## Unrecognised Generics + +When your recursive root is a type parameter of an unrecognised generic, the generic can vanish and be replaced with a strange object in the output type. + +For example, this transform puts recursive children into an unregistered `WeakMap`: + +```ts +const Schemas = v.mutualRecursiveAsync((ref) => ({ + node: v.pipeAsync( + v.objectAsync({ name: v.string(), children: v.arrayAsync(ref('node')) }), + v.transform((node) => ({ + name: node.name, + children: new WeakMap(node.children.map((child) => [child, child])), + })) + ), +})); + +type Output = v.InferOutput; +``` + +The expected `Output` type is: + +```ts +interface Node { + name: string; + children: WeakMap; +} +``` + +But the actual `Output['children']` type comes out as: + +```ts +{ + delete: (key: any) => boolean; + get: (key: any) => any | undefined; + has: (key: any) => boolean; + set: (key: any, value: any) => any; + readonly [Symbol.toStringTag]: string; +} +``` + +The fix is to extend `Generics` as explained on the Generics page. + ## Related The following APIs can be combined with `mutualRecursiveAsync`. @@ -85,6 +128,10 @@ The following APIs can be combined with `mutualRecursiveAsync`. +### Types + + + ### Async ); ``` +## Unrecognised Generics + +When your recursive root is a type parameter of an unrecognised generic, the generic can vanish and be replaced with a strange object in the output type. + +For example, this transform puts recursive children into an unregistered `WeakMap`: + +```ts +const NodeSchema = v.recursiveAsync((self) => + v.pipeAsync( + v.objectAsync({ name: v.string(), children: v.arrayAsync(self) }), + v.transform((node) => ({ + name: node.name, + children: new WeakMap(node.children.map((child) => [child, child])), + })) + ) +); + +type Output = v.InferOutput; +``` + +The expected `Output` type is: + +```ts +interface Node { + name: string; + children: WeakMap; +} +``` + +But the actual `Output['children']` type comes out as: + +```ts +{ + delete: (key: any) => boolean; + get: (key: any) => any | undefined; + has: (key: any) => boolean; + set: (key: any, value: any) => any; + readonly [Symbol.toStringTag]: string; +} +``` + +The fix is to extend `Generics` as explained on the Generics page. + ## Related The following APIs can be combined with `recursiveAsync`. @@ -83,6 +126,10 @@ The following APIs can be combined with `recursiveAsync`. +### Types + + + ### Async ({ })); ``` +## Unrecognised Generics + +When your recursive root is a type parameter of an unrecognised generic, the generic can vanish and be replaced with a strange object in the output type. + +For example, this transform puts recursive children into an unregistered `WeakMap`: + +```ts +const Schemas = v.mutualRecursive((ref) => ({ + node: v.pipe( + v.object({ name: v.string(), children: v.array(ref('node')) }), + v.transform((node) => ({ + name: node.name, + children: new WeakMap(node.children.map((child) => [child, child])), + })) + ), +})); + +type Output = v.InferOutput; +``` + +The expected `Output` type is: + +```ts +interface Node { + name: string; + children: WeakMap; +} +``` + +But the actual `Output['children']` type comes out as: + +```ts +{ + delete: (key: any) => boolean; + get: (key: any) => any | undefined; + has: (key: any) => boolean; + set: (key: any, value: any) => any; + readonly [Symbol.toStringTag]: string; +} +``` + +The fix is to extend `Generics` as explained on the Generics page. + ## Related The following APIs can be combined with `mutualRecursive`. @@ -84,3 +127,7 @@ The following APIs can be combined with `mutualRecursive`. ### Actions + +### Types + + diff --git a/website/src/routes/api/(schemas)/recursive/index.mdx b/website/src/routes/api/(schemas)/recursive/index.mdx index e4fb7092f..cf2609cee 100644 --- a/website/src/routes/api/(schemas)/recursive/index.mdx +++ b/website/src/routes/api/(schemas)/recursive/index.mdx @@ -82,6 +82,49 @@ const CategoryWithLabelSchema = v.pipe( ); ``` +## Unrecognised Generics + +When your recursive root is a type parameter of an unrecognised generic, the generic can vanish and be replaced with a strange object in the output type. + +For example, this transform puts recursive children into an unregistered `WeakMap`: + +```ts +const NodeSchema = v.recursive((self) => + v.pipe( + v.object({ name: v.string(), children: v.array(self) }), + v.transform((node) => ({ + name: node.name, + children: new WeakMap(node.children.map((child) => [child, child])), + })) + ) +); + +type Output = v.InferOutput; +``` + +The expected `Output` type is: + +```ts +interface Node { + name: string; + children: WeakMap; +} +``` + +But the actual `Output['children']` type comes out as: + +```ts +{ + delete: (key: any) => boolean; + get: (key: any) => any | undefined; + has: (key: any) => boolean; + set: (key: any, value: any) => any; + readonly [Symbol.toStringTag]: string; +} +``` + +The fix is to extend `Generics` as explained on the Generics page. + ## Related The following APIs can be combined with `recursive`. @@ -108,3 +151,7 @@ The following APIs can be combined with `recursive`. ### Actions + +### Types + + diff --git a/website/src/routes/api/(types)/Generics/GenericsGenerator.tsx b/website/src/routes/api/(types)/Generics/GenericsGenerator.tsx new file mode 100644 index 000000000..e17d97948 --- /dev/null +++ b/website/src/routes/api/(types)/Generics/GenericsGenerator.tsx @@ -0,0 +1,373 @@ +import { + $, + component$, + sync$, + useComputed$, + useSignal, + useStore, + useVisibleTask$, +} from '@builder.io/qwik'; +import { createHighlighterCoreSync, ThemeRegistrationAny } from 'shiki/core'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; +import typescript from 'shiki/langs/ts.mjs'; +import { Pre } from '~/hooks/useMDXComponents'; +import darkTheme from '../../../../../shiki/pace-theme-dark.json'; +import lightTheme from '../../../../../shiki/pace-theme-light+.json'; +import { generateGeneric } from './generateGeneric'; +import './generics.css'; + +interface GeneratorParameter { + id: number; + name: string; + constraint: string | null; +} + +interface GeneratorEditor { + addConstraint(parameter: GeneratorParameter): Promise; + parameterBlur(parameter: GeneratorParameter): Promise; + constraintBlur(parameter: GeneratorParameter): Promise; +} + +interface GeneratorState { + name: string; + parameters: GeneratorParameter[]; + focus?: { field: string; caret: 'start' | 'end' }; +} + +const fieldArrowNavigation = sync$( + (event: KeyboardEvent, element: HTMLInputElement) => { + if (event.key === ' ') { + event.preventDefault(); + } + if ( + (event.shiftKey && event.key !== 'Tab') || + event.altKey || + event.ctrlKey || + event.metaKey || + (event.key !== ' ' && element.selectionStart !== element.selectionEnd) + ) { + return; + } + const previous = + (event.key === 'Tab' && event.shiftKey) || + (event.key === 'ArrowLeft' && element.selectionStart === 0); + const next = + element.dataset.field === 'name' && + ((event.key === 'Tab' && !event.shiftKey) || + event.key === ' ' || + (event.key === 'ArrowRight' && + element.selectionEnd === element.value.length)); + if (!previous && !next) return; + if (next) event.preventDefault(); + const inputs = Array.from( + element + .closest('.generics-generator-definition')! + .querySelectorAll('input') + ); + const target = inputs[inputs.indexOf(element) + (previous ? -1 : 1)]; + if (target) { + event.preventDefault(); + target.focus(); + const caret = previous ? target.value.length : 0; + target.setSelectionRange(caret, caret); + } + } +); + +const parameterKeyGuard = sync$( + (event: KeyboardEvent, element: HTMLInputElement) => { + if ( + (!event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === 'Tab' || + event.key === ' ' || + (event.key === 'ArrowRight' && + element.selectionStart === element.value.length && + element.selectionEnd === element.value.length)) && + element.value.trim()) || + (event.key === 'Backspace' && !element.value) + ) { + event.preventDefault(); + } + } +); + +const constraintKeyGuard = sync$( + (event: KeyboardEvent, element: HTMLInputElement) => { + if ( + (!event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === 'Tab' || + event.key === ' ' || + (event.key === 'ArrowRight' && + element.selectionStart === element.value.length && + element.selectionEnd === element.value.length))) || + (event.key === 'Backspace' && !element.value) + ) { + event.preventDefault(); + } + } +); + +const highlighter = createHighlighterCoreSync({ + langs: [typescript], + themes: [lightTheme, darkTheme] as ThemeRegistrationAny[], + engine: createJavaScriptRegexEngine(), +}); + +/** + * Generates registrations for the Generics interface. + */ +export const GenericsGenerator = component$(() => { + const state = useStore({ + name: 'WeakMap', + parameters: [ + { id: 0, name: 'K', constraint: 'object' }, + { id: 1, name: 'V', constraint: null }, + ], + }); + const editorElement = useSignal(); + const editor = { + nameInput: $((value: string) => { + state.name = value; + }), + nameKeyDown: $((event: KeyboardEvent) => { + if ( + event.defaultPrevented && + !event.shiftKey && + ['Tab', 'ArrowRight', ' '].includes(event.key) && + !state.parameters.length + ) { + state.parameters.push({ id: 0, name: 'T1', constraint: null }); + state.focus = { field: 'parameter-0', caret: 'start' }; + } + }), + add: $(() => { + state.parameters.push({ + id: + Math.max(-1, ...state.parameters.map((parameter) => parameter.id)) + + 1, + name: `T${state.parameters.length + 1}`, + constraint: null, + }); + }), + parameterInput: $((parameter: GeneratorParameter, value: string) => { + parameter.name = value; + }), + constraintInput: $((parameter: GeneratorParameter, value: string) => { + parameter.constraint = value; + }), + addConstraint: $((parameter: GeneratorParameter) => { + parameter.constraint ??= ''; + state.focus = { field: `constraint-${parameter.id}`, caret: 'start' }; + }), + parameterBlur: $((parameter: GeneratorParameter) => { + if (!parameter.name.trim()) { + state.parameters = state.parameters.filter( + (item) => item.id !== parameter.id + ); + } + }), + constraintBlur: $((parameter: GeneratorParameter) => { + if (!parameter.constraint?.trim()) parameter.constraint = null; + }), + parameterKeyDown: $(async function ( + this: GeneratorEditor, + parameter: GeneratorParameter, + event: KeyboardEvent + ) { + if ( + !event.defaultPrevented || + event.shiftKey || + !['Tab', 'ArrowRight', ' ', 'Backspace'].includes(event.key) + ) { + return; + } + if (event.key !== 'Backspace' && parameter.name.trim()) { + await this.addConstraint(parameter); + } else if (event.key === 'Backspace' && !parameter.name) { + const index = state.parameters.findIndex( + (item) => item.id === parameter.id + ); + state.focus = { + field: + index > 0 + ? `${state.parameters[index - 1].constraint !== null ? 'constraint' : 'parameter'}-${state.parameters[index - 1].id}` + : 'name', + caret: 'end', + }; + await this.parameterBlur(parameter); + } + }), + constraintKeyDown: $(async function ( + this: GeneratorEditor, + parameter: GeneratorParameter, + event: KeyboardEvent + ) { + if (!event.defaultPrevented || event.shiftKey) return; + if (['Tab', 'ArrowRight', ' '].includes(event.key)) { + const index = state.parameters.findIndex( + (item) => item.id === parameter.id + ); + let next = state.parameters[index + 1]; + if (!next) { + next = { + id: Math.max(-1, ...state.parameters.map((item) => item.id)) + 1, + name: `T${state.parameters.length + 1}`, + constraint: null, + }; + state.parameters.push(next); + } + state.focus = { field: `parameter-${next.id}`, caret: 'start' }; + } else if (event.key === 'Backspace' && !parameter.constraint) { + await this.constraintBlur(parameter); + state.focus = { field: `parameter-${parameter.id}`, caret: 'end' }; + } + }), + }; + + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(({ track }) => { + const request = track(() => state.focus); + if (request) { + const input = editorElement.value?.querySelector( + `[data-field="${request.field}"]` + ); + if (input) { + input.focus(); + const caret = request.caret === 'end' ? input.value.length : 0; + input.setSelectionRange(caret, caret); + } + } + }); + const code = useComputed$(() => + state.name.trim() + ? generateGeneric( + state.name.trim(), + state.parameters + .filter((parameter) => parameter.name.trim()) + .map((parameter) => ({ + name: parameter.name, + constraint: parameter.constraint ?? '', + })) + ) + : '' + ); + + const highlightedCode = useComputed$(() => + highlighter.codeToHtml(code.value, { + lang: 'ts', + themes: { light: 'Pace Light', dark: 'Pace Dark' }, + transformers: [ + { + root(node) { + const pre = node.children[0]; + if (pre.type === 'element') { + const code = pre.children[0]; + if (code.type === 'element') node.children = code.children; + } + }, + }, + ], + }) + ); + + return ( +
+

Enter your generic’s name and type parameters:

+
+ editor.nameInput(element.value)} + onKeyDown$={[ + fieldArrowNavigation, + $((event: KeyboardEvent) => editor.nameKeyDown(event)), + ]} + /> + < + {state.parameters.map((arg, index) => ( + + {index > 0 && , } + + editor.parameterInput(arg, element.value) + } + onKeyDown$={[ + fieldArrowNavigation, + parameterKeyGuard, + $((event: KeyboardEvent) => + editor.parameterKeyDown(arg, event) + ), + ]} + onBlur$={() => editor.parameterBlur(arg)} + /> + {arg.constraint !== null ? ( + <> + extends + + editor.constraintInput(arg, element.value) + } + onKeyDown$={[ + fieldArrowNavigation, + constraintKeyGuard, + $((event: KeyboardEvent) => + editor.constraintKeyDown(arg, event) + ), + ]} + onBlur$={() => editor.constraintBlur(arg)} + /> + + ) : ( + + )} + + ))} + + > +
+

+ The code below is generated from your input. Add it to a TypeScript file + in your project. +

+ {code.value ? ( +
+
+            
+          
+
+ ) : ( +

Enter the constructor name.

+ )} +
+ ); +}); diff --git a/website/src/routes/api/(types)/Generics/generateGeneric.test.ts b/website/src/routes/api/(types)/Generics/generateGeneric.test.ts new file mode 100644 index 000000000..ca7f86626 --- /dev/null +++ b/website/src/routes/api/(types)/Generics/generateGeneric.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { test } from 'node:test'; +import ts from 'typescript'; +import { generateGeneric } from './generateGeneric.ts'; + +/** + * Compiles generated registrations without writing fixture files. + */ +function compile(source: string): string[] { + const fixture = resolve(import.meta.dirname, 'generated.fixture.ts'); + const options: ts.CompilerOptions = { + strict: true, + skipLibCheck: true, + noEmit: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowImportingTsExtensions: true, + types: [], + paths: { + valibot: [ + resolve(import.meta.dirname, '../../../../../../library/src/index.ts'), + ], + }, + }; + const host = ts.createCompilerHost(options); + const readFile = host.readFile; + host.readFile = (path) => (path === fixture ? source : readFile(path)); + const program = ts.createProgram([fixture], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + ); +} + +const assertions = ` +import type * as v from 'valibot'; +type Equal = (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; +`; + +test('indents nested constraints', () => { + const source = generateGeneric('Triple', [ + { name: 'A', constraint: 'object' }, + { name: 'B', constraint: '' }, + { name: 'C', constraint: 'string' }, + ]); + assert.ok( + source.includes(` applyArgs: TArgs extends [infer A, infer B, infer C] + ? [A] extends [object] + ? [C] extends [string] + ? Triple + : never + : never + : never;`) + ); +}); + +test('uses single quotes and escapes property names', () => { + for (const name of ['WeakMap', "A'B", 'A\\\\B', 'A"B', 'A\\nB']) { + const source = generateGeneric(name, []); + const property = source.split('\n').find((line) => line.endsWith(': {')); + assert.ok(property); + const quoted = property.trim().slice(0, -3); + assert.equal(quoted[0], "'"); + assert.equal(quoted.at(-1), "'"); + const parsed = ts.createSourceFile( + 'property.ts', + `interface Example { ${quoted}: unknown }`, + ts.ScriptTarget.Latest + ); + const declaration = parsed.statements[0] as ts.InterfaceDeclaration; + assert.equal((declaration.members[0].name as ts.StringLiteral).text, name); + } +}); + +test('generates a constrained WeakMap registration', () => { + const source = generateGeneric('WeakMap', [ + { name: 'K', constraint: 'object' }, + { name: 'V', constraint: '' }, + ]); + assert.deepEqual( + compile(`${source}${assertions} +type Extracted = Assert, never>['WeakMap']['getArgs'], [object, string]>>; +type Unmatched = Assert['WeakMap']['getArgs'], never>>; +type Applied = Assert['WeakMap']['applyArgs'], WeakMap>>; +type Invalid = Assert['WeakMap']['applyArgs'], never>>; +`), + [] + ); +}); + +test('generates an unconstrained registration with three arguments', () => { + const source = generateGeneric('Triple', [ + { name: 'A', constraint: '' }, + { name: 'B', constraint: '' }, + { name: 'C', constraint: '' }, + ]); + assert.deepEqual( + compile(`${source}${assertions} +interface Triple { first: A; second: B; third: C } +type Extracted = Assert, never>['Triple']['getArgs'], [string, number, boolean]>>; +type Applied = Assert['Triple']['applyArgs'], Triple>>; +`), + [] + ); +}); + +test('generates constraints that reference another argument', () => { + const source = generateGeneric('Entry', [ + { name: 'TObject', constraint: 'object' }, + { name: 'TKey', constraint: 'keyof TObject' }, + ]); + assert.deepEqual( + compile(`${source}${assertions} +interface Entry { object: TObject; key: TKey } +type Extracted = Assert, never>['Entry']['getArgs'], [{ name: string }, 'name']>>; +type Applied = Assert['Entry']['applyArgs'], Entry<{ name: string }, 'name'>>>; +type Invalid = Assert['Entry']['applyArgs'], never>>; +`), + [] + ); +}); diff --git a/website/src/routes/api/(types)/Generics/generateGeneric.ts b/website/src/routes/api/(types)/Generics/generateGeneric.ts new file mode 100644 index 000000000..48b15f777 --- /dev/null +++ b/website/src/routes/api/(types)/Generics/generateGeneric.ts @@ -0,0 +1,42 @@ +/** + * Generic argument definition. + */ +export interface GenericArgument { + name: string; + constraint: string; +} + +/** + * Creates a module augmentation for a generic constructor. + */ +export function generateGeneric( + name: string, + args: readonly GenericArgument[] +): string { + const quotedName = `'${JSON.stringify(name) + .slice(1, -1) + .replace(/\\"/g, '"') + .replace(/'/g, "\\'")}'`; + const inferredArgs = args.map((arg) => `infer ${arg.name}`).join(', '); + const appliedArgs = args.map((arg) => arg.name).join(', '); + let applyArgs = args.length ? `${name}<${appliedArgs}>` : name; + for (let index = args.length - 1; index >= 0; index--) { + const arg = args[index]; + if (arg.constraint.trim()) { + applyArgs = `[${arg.name}] extends [${arg.constraint}]\n ? ${applyArgs.replace(/\n/g, '\n ')}\n : never`; + } + } + return `import 'valibot'; + +declare module 'valibot' { + interface Generics { + ${quotedName}: { + getArgs: T extends ${name}${args.length ? `<${inferredArgs}>` : ''} ? [${appliedArgs}] : never; + applyArgs: TArgs extends [${inferredArgs}] + ? ${applyArgs} + : never; + }; + } +} +`; +} diff --git a/website/src/routes/api/(types)/Generics/generics.css b/website/src/routes/api/(types)/Generics/generics.css new file mode 100644 index 000000000..f380b9693 --- /dev/null +++ b/website/src/routes/api/(types)/Generics/generics.css @@ -0,0 +1,77 @@ +.generics-generator { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; + min-width: 0; + margin-inline: 2rem; +} + +@media (min-width: 64rem) { + .generics-generator { + margin-inline: 2.5rem; + } +} + +.generics-generator > * { + min-width: 0; +} + +.generics-generator-definition { + display: flex; + align-items: center; + overflow-x: auto; + border: 1px solid var(--color-slate-300); + border-radius: 0.75rem; + padding: 1rem; + font-family: var(--font-mono); + white-space: pre; +} + +.generics-generator-argument { + display: inline-flex; + align-items: center; +} + +.generics-generator-definition > *, +.generics-generator-argument > * { + flex-shrink: 0; +} + +.generics-generator input { + field-sizing: content; + min-width: 1ch; + border: 1px solid var(--color-slate-400); + border-radius: 0.25rem; + background-color: var(--color-slate-100); + padding: 0.25rem; + color: var(--color-sky-600); + font: inherit; +} + +.generics-generator-definition button { + margin-inline: 0.25rem; + border: 1px solid var(--color-slate-300); + border-radius: 0.25rem; + padding: 0.125rem 0.375rem; + font-size: 0.75rem; + cursor: pointer; +} + +.generics-generator-keyword { + color: var(--color-red-600); +} + +.dark .generics-generator-definition, +.dark .generics-generator-definition button { + border-color: var(--color-slate-700); +} + +.dark .generics-generator input { + border-color: var(--color-slate-600); + background-color: var(--color-slate-800); + color: var(--color-sky-400); +} + +.dark .generics-generator-keyword { + color: var(--color-red-400); +} diff --git a/website/src/routes/api/(types)/Generics/index.mdx b/website/src/routes/api/(types)/Generics/index.mdx new file mode 100644 index 000000000..4233cc453 --- /dev/null +++ b/website/src/routes/api/(types)/Generics/index.mdx @@ -0,0 +1,45 @@ +--- +title: Generics +description: Extensible registry for generic type constructors. +contributors: + - james-tindal +--- + +import { Link, Property } from '~/components'; +import { GenericsGenerator } from './GenericsGenerator'; +import { properties } from './properties'; + +# Generics + +Extensible registry for generic type constructors. + +## Generics + +- `T` +- `TArgs` + +## Definition + +- `Generics` + +The interface has no entries by default. Each registration describes how to extract and apply the arguments of a generic constructor. + +| Property | Purpose | +| ----------- | -------------------------------------------------------------------------------------- | +| `getArgs` | Extracts the arguments of `T` as a tuple. Returns `never` for types that do not match. | +| `applyArgs` | Reconstructs the generic from the resolved arguments in `TArgs`. | + +## Recursive schemas + +Currently, only the four recursive APIs use this registry: + +- `recursive` +- `recursiveAsync` +- `mutualRecursive` +- `mutualRecursiveAsync` + +## Register a generic + +Add a module augmentation to a TypeScript file that your project includes. + + diff --git a/website/src/routes/api/(types)/Generics/properties.ts b/website/src/routes/api/(types)/Generics/properties.ts new file mode 100644 index 000000000..cb59010ea --- /dev/null +++ b/website/src/routes/api/(types)/Generics/properties.ts @@ -0,0 +1,15 @@ +import type { PropertyProps } from '~/components'; + +export const properties: Record = { + T: { + type: 'unknown', + modifier: 'extends', + }, + TArgs: { + type: 'unknown', + modifier: 'extends', + }, + Generics: { + type: { type: 'object', entries: [] }, + }, +}; diff --git a/website/src/routes/api/menu.md b/website/src/routes/api/menu.md index 337d78998..a5883b10a 100644 --- a/website/src/routes/api/menu.md +++ b/website/src/routes/api/menu.md @@ -403,6 +403,7 @@ - [GenericPipeActionAsync](/api/GenericPipeActionAsync/) - [GenericPipeItem](/api/GenericPipeItem/) - [GenericPipeItemAsync](/api/GenericPipeItemAsync/) +- [Generics](/api/Generics/) - [GenericSchema](/api/GenericSchema/) - [GenericSchemaAsync](/api/GenericSchemaAsync/) - [GenericTransformation](/api/GenericTransformation/) diff --git a/website/src/styles/root.css b/website/src/styles/root.css index 1782b5c30..d5b3da75b 100644 --- a/website/src/styles/root.css +++ b/website/src/styles/root.css @@ -186,9 +186,12 @@ .mdx > :is(ul, ol) { @apply mr-8 ml-12 lg:mr-10 lg:ml-14; } - .mdx > :is(img, .code-wrapper, .table-wrapper) { + .mdx > :is(img, .table-wrapper) { @apply mx-3 lg:mx-10 2xl:mx-0; } + .mdx > .code-wrapper { + @apply mx-8 lg:mx-10; + } /* Vertical spacing */ .mdx > :is(h1, h2, h3, h4, p) + p { From ff87d6963a94308935ce939da75bc1e4127af325 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 19:18:43 +0100 Subject: [PATCH 21/39] test: reproduce recursive schema review findings --- .../src/schemas/recursive/recursive.test-d.ts | 110 ++++++++++++++++++ .../src/schemas/recursive/recursive.test.ts | 59 ++++++++++ .../convertSchema/convertSchema.test.ts | 66 +++++++++++ .../toJsonSchemaDefs/toJsonSchemaDefs.test.ts | 10 ++ 4 files changed, 245 insertions(+) diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index 63196910f..bb074d68a 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -1,5 +1,6 @@ import { describe, expectTypeOf, test } from 'vitest'; import { readonly, transform } from '../../actions/index.ts'; +import * as v from '../../index.ts'; import { pipe } from '../../methods/index.ts'; import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; import { array, type ArrayIssue, type ArraySchema } from '../array/index.ts'; @@ -409,3 +410,112 @@ describe('recursive', () => { expectTypeOf(Category).toExtend(); }); }); + +describe('CodeRabbit recursive type regressions', () => { + test('resolves string references to numeric entry keys', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const group = v.mutualRecursive((ref) => ({ + 0: v.string(), + node: v.object({ value: ref('0') }), + })); + expectTypeOf>().toEqualTypeOf<{ + value: string; + }>(); + }); + + test('preserves branded primitive references', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const group = v.mutualRecursive((ref) => ({ + leaf: v.number(), + node: v.object({ value: v.pipe(ref('leaf'), v.brand('Leaf')) }), + })); + type Output = v.InferOutput; + expectTypeOf().toExtend(); + }); + + test('preserves properties added to unnamed references', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const schema = v.recursive((self) => + v.object({ + child: v.optional( + v.pipe( + self, + v.transform((value) => ({ ...value, label: 'child' })) + ) + ), + }) + ); + type Output = v.InferOutput; + expectTypeOf>().toExtend<{ label: string }>(); + }); + + test('rejects unmatched references in marker intersections', () => { + // @ts-expect-error The extra property references an absent definition. + v.mutualRecursive((ref) => ({ + a: v.intersect([ref('b'), v.object({ extra: ref('missing') })]), + b: v.object({ value: v.string() }), + })); + }); + + test('resolves references in distinct subtype children', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const schema = v.recursive((self) => + v.object({ + next: v.optional( + v.object({ next: v.optional(v.never()), child: self }) + ), + }) + ); + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- The fixture requires this exact subtype relationship to reproduce ancestor skipping. + expectTypeOf<{ next: { child: {} } }>().toExtend< + v.InferInput + >(); + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- The fixture requires this exact subtype relationship to reproduce ancestor skipping. + expectTypeOf<{ next: { child: {} } }>().toExtend< + v.InferOutput + >(); + }); + + test('includes recursive unions in nested issue types', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const schema = v.recursive((self) => v.union([v.string(), v.array(self)])); + type Issue = v.InferIssue; + type Subissue = NonNullable[number]; + expectTypeOf>().not.toBeNever(); + }); + + test('includes referenced issues in mutual union subissues', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const group = v.mutualRecursive((ref) => ({ + a: v.union([v.string(), ref('b')]), + b: v.number(), + })); + type Issue = Extract, { type: 'union' }>; + type Subissue = NonNullable[number]; + expectTypeOf().toExtend(); + }); + + test('resolves markers in function return types', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const schema = v.recursive((self) => + v.object({ + name: v.string(), + getChild: v.pipe(v.any(), v.returns(self)), + }) + ); + type Output = v.InferOutput; + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves markers in function parameter types', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The schema is used only in type assertions. + const schema = v.recursive((self) => + v.object({ + name: v.string(), + setChild: v.pipe(v.any(), v.args(v.tuple([self]))), + }) + ); + type Output = v.InferOutput; + expectTypeOf[0]>().toEqualTypeOf(); + }); +}); diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index 563041d84..c297ec502 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from 'vitest'; import { returns } from '../../actions/index.ts'; +import * as v from '../../index.ts'; import { parse, pipe } from '../../methods/index.ts'; import type { FailureDataset, InferIssue } from '../../types/index.ts'; import { expectNoSchemaIssue } from '../../vitest/index.ts'; @@ -166,3 +167,61 @@ describe('recursive', () => { expect(definition).toHaveBeenCalledTimes(1); }); }); + +describe('CodeRabbit recursive runtime regressions', () => { + test('constructs symbol-keyed mutual entries', () => { + const key = Symbol('entry'); + const group = v.mutualRecursive(() => ({ [key]: v.string() })); + expect(group[key]).toBeDefined(); + expect(v.parse(group[key], 'value')).toBe('value'); + }); + + test('constructs non-enumerable mutual entries', () => { + const entries = { node: v.string() }; + Object.defineProperty(entries, 'node', { enumerable: false }); + const group = v.mutualRecursive(() => entries); + expect(group.node).toBeDefined(); + expect(v.parse(group.node, 'value')).toBe('value'); + }); + + test('accepts schema-shaped application defaults', () => { + const value = { kind: 'schema', owner: 'application' }; + const schema = v.recursive(() => + v.object({ value: v.optional(v.any(), value) }) + ); + expect(v.parse(schema, {})).toStrictEqual({ value }); + }); + + test.each(['owner', 'reference', '~standard', '~types'])( + 'rejects foreign references in an entry named %s', + (key) => { + let foreign: v.GenericSchema = v.never(); + v.recursive((self) => { + foreign = self; + return v.string(); + }); + expect(() => v.recursive(() => v.object({ [key]: foreign }))).toThrow( + 'Recursive reference used outside its recursive schema' + ); + } + ); + + test('returns recursive union issues at runtime', () => { + const schema = v.recursive((self) => v.union([v.string(), v.array(self)])); + const result = v.safeParse(schema, [false]); + expect(result.issues?.[0].issues).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'union' })]) + ); + }); + + test('returns referenced issues inside mutual union issues', () => { + const group = v.mutualRecursive((ref) => ({ + a: v.union([v.string(), ref('b')]), + b: v.number(), + })); + const result = v.safeParse(group.a, false); + expect(result.issues?.[0].issues).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'number' })]) + ); + }); +}); diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts index 7cd9f0dd4..7c2a1d0db 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts @@ -1,5 +1,6 @@ import * as v from 'valibot'; import { describe, expect, test, vi } from 'vitest'; +import { toJsonSchema } from '../../functions/toJsonSchema/toJsonSchema.ts'; import { ReferenceMap } from '../../utils/index.ts'; import { createContext } from '../../vitest/index.ts'; import { convertSchema } from './convertSchema.ts'; @@ -2046,3 +2047,68 @@ describe('convertSchema', () => { }); }); }); + +describe('CodeRabbit recursive conversion regressions', () => { + test('preserves child Ref identity for reference overrides', () => { + let child: v.GenericSchema = v.never(); + const schema = v.recursive((self) => { + child = self; + return v.object({ child: v.optional(self) }); + }); + const result = toJsonSchema(schema, { + overrideRef: ({ valibotSchema }) => + valibotSchema === child ? '#/external/child' : undefined, + }); + const definition = Object.values(result.$defs ?? {})[0]; + expect(definition.properties?.child.$ref).toBe('#/external/child'); + }); + + test('does not emit Draft-07 constraints as ignored Ref siblings', () => { + const schema = v.recursive((self) => + v.object({ + child: v.optional(v.pipe(self, v.metadata({ minProperties: 2 }))), + }) + ); + const result = toJsonSchema(schema, { target: 'draft-07' }); + const definition = Object.values(result.$defs ?? {})[0]; + const child = definition.properties?.child; + expect(child).toBeDefined(); + expect(JSON.stringify(child)).toContain('"minProperties":2'); + expect(child?.$ref !== undefined && child.minProperties !== undefined).toBe( + false + ); + }); + + test('preserves an empty configured definition ID', () => { + const schema = v.recursive((self) => v.object({ child: v.optional(self) })); + const result = toJsonSchema(schema, { definitions: { '': schema } }); + expect(result.$ref).toBe('#/$defs/'); + expect(Object.keys(result.$defs ?? {})).toStrictEqual(['']); + expect(result.$defs?.[''].properties?.child.$ref).toBe('#/$defs/'); + }); + + test('registers named mutual targets before converting parents', () => { + const group = v.mutualRecursive((ref) => ({ + parent: v.object({ child: ref('child') }), + child: v.string(), + })); + const result = toJsonSchema(group.parent, { definitions: group }); + expect(Object.keys(result.$defs ?? {}).sort()).toStrictEqual([ + 'child', + 'parent', + ]); + expect(result.$defs?.parent.properties?.child.$ref).toBe('#/$defs/child'); + }); + + test('serializes a recursive __proto__ definition as an own property', () => { + const schema = v.recursive((self) => v.object({ child: v.optional(self) })); + const result = toJsonSchema(schema, { + definitions: { ['__proto__']: schema }, + }); + expect(result.$ref).toBe('#/$defs/__proto__'); + expect(Object.hasOwn(result.$defs ?? {}, '__proto__')).toBe(true); + expect(JSON.parse(JSON.stringify(result)).$defs).toHaveProperty( + '__proto__' + ); + }); +}); diff --git a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts index 083b9ac39..36e2cc335 100644 --- a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts +++ b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.test.ts @@ -7,6 +7,16 @@ import { toJsonSchemaDefs } from './toJsonSchemaDefs.ts'; console.warn = vi.fn(); describe('toJsonSchemaDefs', () => { + test('reuses the named mutual target when the parent is converted first', () => { + const group = v.mutualRecursive((ref) => ({ + parent: v.object({ child: ref('child') }), + child: v.string(), + })); + const definitions = toJsonSchemaDefs(group); + expect(definitions.parent.properties?.child.$ref).toBe('#/$defs/child'); + expect(Object.keys(definitions).sort()).toStrictEqual(['child', 'parent']); + }); + describe('should convert schema', () => { test('for simple schema', () => { expect( From a716570d1ca4b715fca7870ef40b9c7297cb3687 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 22:32:48 +0100 Subject: [PATCH 22/39] fix: register named mutual targets before definition conversion --- .../functions/toJsonSchemaDefs/toJsonSchemaDefs.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts index d85fb6eb6..bcc778522 100644 --- a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts +++ b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts @@ -33,7 +33,17 @@ export function toJsonSchemaDefs< // Add provided definitions to context, if necessary for (const key in definitions) { - context.referenceMap.set(definitions[key], key); + const definition = definitions[key]; + context.referenceMap.set(definition, key); + if ( + definition.type === 'recursive' || + definition.type === 'mutualRecursive' + ) { + context.referenceMap.set( + (definition as v.RecursiveSchema | v.MutualRecursiveSchema).wrapped, + key + ); + } } for (const key in definitions) { context.definitions[key] = convertSchema( From d2e92375e4c6b5a485d2ea0c3a5d107a9cef63d0 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 22:32:48 +0100 Subject: [PATCH 23/39] fix: store prototype-named schema definitions as own properties --- .../converters/convertSchema/convertSchema.ts | 13 ++++++------ .../functions/toJsonSchema/toJsonSchema.ts | 21 ++++++++++++------- .../toJsonSchemaDefs/toJsonSchemaDefs.ts | 21 ++++++++++++------- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index b09abd372..53fcea4bb 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -642,13 +642,12 @@ export function convertSchema( if ( !Object.prototype.hasOwnProperty.call(context.definitions, referenceId) ) { - context.definitions[referenceId] = convertSchema( - {}, - wrapped, - config, - context, - true - ); + Object.defineProperty(context.definitions, referenceId, { + value: convertSchema({}, wrapped, config, context, true), + enumerable: true, + writable: true, + configurable: true, + }); } // Return definition directly, if reference is skipped diff --git a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts index 63bedd014..0313b7b11 100644 --- a/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts +++ b/packages/to-json-schema/src/functions/toJsonSchema/toJsonSchema.ts @@ -46,14 +46,19 @@ export function toJsonSchema( } } for (const key in definitions) { - context.definitions[key] = convertSchema( - {}, - // @ts-expect-error - definitions[key], - config, - context, - true - ); + Object.defineProperty(context.definitions, key, { + value: convertSchema( + {}, + // @ts-expect-error + definitions[key], + config, + context, + true + ), + enumerable: true, + writable: true, + configurable: true, + }); } } diff --git a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts index bcc778522..fdf64bea5 100644 --- a/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts +++ b/packages/to-json-schema/src/functions/toJsonSchemaDefs/toJsonSchemaDefs.ts @@ -46,14 +46,19 @@ export function toJsonSchemaDefs< } } for (const key in definitions) { - context.definitions[key] = convertSchema( - {}, - // @ts-expect-error - definitions[key], - config, - context, - true - ); + Object.defineProperty(context.definitions, key, { + value: convertSchema( + {}, + // @ts-expect-error + definitions[key], + config, + context, + true + ), + enumerable: true, + writable: true, + configurable: true, + }); } // Return JSON Schema definitions From e1ad175ce535fdcc2363390d99194e5bd6aea494 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 22:32:48 +0100 Subject: [PATCH 24/39] fix: preserve recursive reference identity for overrides --- .../src/converters/convertSchema/convertSchema.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index 53fcea4bb..4b5d94936 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -622,8 +622,12 @@ export function convertSchema( ) as SchemaOrPipe; // Reuse a registered target without expanding its references again. - if (recursiveSchema.type === 'ref' && context.referenceMap.has(wrapped)) { - return convertSchema(jsonSchema, wrapped, config, context); + if (recursiveSchema.type === 'ref') { + const referenceId = context.referenceMap.get(wrapped); + if (referenceId !== undefined) { + context.referenceMap.set(recursiveSchema, referenceId); + return convertSchema(jsonSchema, recursiveSchema, config, context); + } } // Get reference ID of recursive Valibot schema From f405bdacdbec0bb7a26ebf7b760f86084a94bf60 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 22:32:48 +0100 Subject: [PATCH 25/39] fix: preserve pipeline constraints beside recursive references --- .../src/converters/convertSchema/convertSchema.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index 4b5d94936..1034a8c83 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -258,6 +258,19 @@ export function convertSchema( } } + // Preserve pipeline constraints when reference siblings are ignored. + if ( + config?.target !== 'draft-2020-12' && + jsonSchema.$ref !== undefined && + Object.keys(jsonSchema).length > 1 + ) { + const { $ref, ...constraints } = jsonSchema; + return { + ...constraints, + allOf: [{ $ref }, ...(constraints.allOf ?? [])], + }; + } + // Return converted JSON Schema return jsonSchema; } From de22ea0d645474c982c0132027d55af54c15db0e Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 22:46:20 +0100 Subject: [PATCH 26/39] fix: identify recursive references with a private symbol --- library/src/schemas/recursive/_recursive.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index c3b5286aa..66d87e79f 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -315,6 +315,13 @@ export const OwnerSymbol: unique symbol = Symbol('recursive owner'); */ const RecursiveSchemaSymbol: unique symbol = Symbol('recursive schema'); +/** + * Recursive reference brand. + * + * @internal + */ +const RefSymbol: unique symbol = Symbol('recursive reference'); + type AnyRecursiveSchema = | RecursiveSchema | RecursiveSchemaAsync @@ -425,7 +432,7 @@ export function checkRecursiveReferences( } seen.add(value); const object = value as Record; - if (['schema', 'transformation'].includes(object.kind as string)) { + if (RefSymbol in object) { if (object.owner !== undefined && object.owner !== owner) { throw Error('Recursive reference used outside its recursive schema'); } @@ -561,6 +568,7 @@ export function createRef( // The marker describes a type-level reference, not the delegated runtime value. // The recursive resolver replaces it with the target schema's input and output. return _standardSchema({ + [RefSymbol]: true, kind: 'schema' as const, type: owner.async ? ('refAsync' as const) : ('ref' as const), expects: 'unknown', From 0286582068db06ea989958558f6f63554b15bc39 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 22:54:12 +0100 Subject: [PATCH 27/39] fix: preserve all own mutual recursive entries --- library/src/schemas/mutualRecursive/_mutualRecursive.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/library/src/schemas/mutualRecursive/_mutualRecursive.ts b/library/src/schemas/mutualRecursive/_mutualRecursive.ts index 1d2288b8c..c5620de9b 100644 --- a/library/src/schemas/mutualRecursive/_mutualRecursive.ts +++ b/library/src/schemas/mutualRecursive/_mutualRecursive.ts @@ -106,12 +106,13 @@ export function createMutualRecursive( const entries = owner.build(createRefFactory(owner)); setEntries(entries); const seen = new Set(); - const schemas: Record = Object.create(null); - for (const key of Object.keys(entries)) { - checkRecursiveReferences(entries[key], owner, seen); + const schemas: Record = Object.create(null); + for (const key of Reflect.ownKeys(entries)) { + const entry = (entries as Record)[key]; + checkRecursiveReferences(entry, owner, seen); schemas[key] = createRecursiveSchema< Entries[string] & { readonly wrapped: Entries[string] } - >(entries[key], owner, owner.type, owner.reference); + >(entry, owner, owner.type, owner.reference); } return schemas; } From 8063e72766a8e7480c52419fdc1d15e86179509d Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 23:21:59 +0100 Subject: [PATCH 28/39] preserve branded primitives in recursive references --- library/src/schemas/recursive/_recursive.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 66d87e79f..187adfc87 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -218,9 +218,11 @@ type ResolveRecursiveContainer = : TValue : ResolveRegisteredGeneric; -type MergeReference = { - [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; -}; +type MergeReference = TValue extends object + ? { + [TKey in keyof (TValue & TExtra)]: (TValue & TExtra)[TKey]; + } + : TValue & TExtra; type ResolveRecursiveValue = IsAny extends true From 460089e05180ca1f487e460f29b0f6e130cd5dff Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 23:21:59 +0100 Subject: [PATCH 29/39] resolve numeric mutual recursive entry keys as strings --- .../src/schemas/mutualRecursive/_mutualRecursive.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/library/src/schemas/mutualRecursive/_mutualRecursive.ts b/library/src/schemas/mutualRecursive/_mutualRecursive.ts index c5620de9b..e21031239 100644 --- a/library/src/schemas/mutualRecursive/_mutualRecursive.ts +++ b/library/src/schemas/mutualRecursive/_mutualRecursive.ts @@ -118,10 +118,14 @@ export function createMutualRecursive( } type EntryInputs = { - [TKey in keyof TEntries]: InferInput; + [TKey in keyof TEntries as `${TKey & (string | number)}`]: InferInput< + TEntries[TKey] + >; }; type EntryOutputs = { - [TKey in keyof TEntries]: InferOutput; + [TKey in keyof TEntries as `${TKey & (string | number)}`]: InferOutput< + TEntries[TKey] + >; }; type ReferenceNames = @@ -131,7 +135,7 @@ type UnmatchedReferences = Exclude< ReferenceNames< RecursiveReferences | EntryOutputs> >, - keyof TEntries + keyof EntryInputs >; interface Error { From b930d0f2baa8c261e8cf0758f9053f10b939f331 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Mon, 9 Nov 2026 23:55:59 +0100 Subject: [PATCH 30/39] preserve extra properties on unnamed recursive references --- library/src/schemas/recursive/_recursive.ts | 23 ++++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 187adfc87..2d924ae94 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -224,22 +224,25 @@ type MergeReference = TValue extends object } : TValue & TExtra; +type ResolveRecursiveReference = + Exclude extends never + ? ResolveRecursiveValue + : MergeReference< + ResolveRecursiveValue, + ResolveRecursiveContainer< + Omit, + TRoot + > + >; + type ResolveRecursiveValue = IsAny extends true ? TValue : TValue extends RecursiveMarker ? TKey extends undefined - ? ResolveRecursiveValue + ? ResolveRecursiveReference : TKey extends keyof TRoot - ? Exclude extends never - ? ResolveRecursiveValue - : MergeReference< - ResolveRecursiveValue, - ResolveRecursiveContainer< - Omit, - TRoot - > - > + ? ResolveRecursiveReference : never : [RecursiveReferences] extends [never] ? TValue From 84ea92b52e7eaa709b163a78750d34e352dfd7c7 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 00:16:12 +0100 Subject: [PATCH 31/39] collect recursive references from marker intersections --- library/src/schemas/recursive/_recursive.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 2d924ae94..aeb6775f7 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -277,7 +277,12 @@ export type RecursiveReferences = IsAny extends true ? never : TValue extends RecursiveMarker - ? TValue + ? + | TValue + | RecursiveReferences< + Omit, + TSeen + > : [TValue] extends [TSeen] ? never : TValue extends From 4b4e6f8bebd9e35a936dfe47cfab73cea57d8b96 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 00:22:18 +0100 Subject: [PATCH 32/39] avoid skipping distinct subtypes in recursive reference discovery --- library/src/schemas/recursive/_recursive.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index aeb6775f7..d7de3a1d8 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -270,6 +270,14 @@ export type ResolveRecursiveOutput< type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; +type Equal = B extends unknown + ? [A] extends [B] + ? [B] extends [A] + ? true + : false + : false + : never; + /** * Collects recursive references from a value. */ @@ -283,7 +291,7 @@ export type RecursiveReferences = Omit, TSeen > - : [TValue] extends [TSeen] + : true extends Equal ? never : TValue extends | Map From 97ee3cb5e7f399e63964b5cd973c9587fa1e1021 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 00:37:45 +0100 Subject: [PATCH 33/39] resolve recursive references in function return types --- library/src/schemas/recursive/_recursive.ts | 36 ++++++++++--------- .../recursive/recursive.references.test.ts | 2 ++ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index d7de3a1d8..91eb4a73f 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -208,14 +208,16 @@ type ResolveRecursiveContainer = : TValue extends readonly unknown[] ? ResolveRecursiveArray : [RegisteredGeneric] extends [never] - ? TValue extends object - ? { - [TKey in keyof TValue]: ResolveRecursiveValue< - TValue[TKey], - TRoot - >; - } - : TValue + ? TValue extends (...args: infer TArgs) => infer TReturn + ? (...args: TArgs) => ResolveRecursiveValue + : TValue extends object + ? { + [TKey in keyof TValue]: ResolveRecursiveValue< + TValue[TKey], + TRoot + >; + } + : TValue : ResolveRegisteredGeneric; type MergeReference = TValue extends object @@ -305,14 +307,16 @@ export type RecursiveReferences = : TValue extends readonly (infer TItem)[] ? RecursiveReferences : [RegisteredGeneric] extends [never] - ? TValue extends object - ? { - [TKey in keyof TValue]-?: RecursiveReferences< - TValue[TKey], - TSeen | TValue - >; - }[keyof TValue] - : never + ? TValue extends (...args: never[]) => infer TReturn + ? RecursiveReferences + : TValue extends object + ? { + [TKey in keyof TValue]-?: RecursiveReferences< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] + : never : RegisteredGeneric extends { readonly args: infer TArgs extends readonly unknown[]; } diff --git a/library/src/schemas/recursive/recursive.references.test.ts b/library/src/schemas/recursive/recursive.references.test.ts index 752c58f5b..1fb12f186 100644 --- a/library/src/schemas/recursive/recursive.references.test.ts +++ b/library/src/schemas/recursive/recursive.references.test.ts @@ -136,12 +136,14 @@ describe('recursive reference traversal', () => { foreignMessage ); expect(() => + // @ts-expect-error An unnamed foreign reference is invalid in mutual definitions. mutualRecursive(() => ({ root: pipe(inner, returns(foreign)) })) ).toThrowError(foreignMessage); expect(() => recursiveAsync(() => pipeAsync(innerAsync, returns(foreign))) ).toThrowError(foreignMessage); expect(() => + // @ts-expect-error An unnamed foreign reference is invalid in mutual definitions. mutualRecursiveAsync(() => ({ root: pipeAsync(innerAsync, returns(foreign)), })) From 30a6cd8bbf258a001468348b1aab0bcea3096d98 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 00:40:23 +0100 Subject: [PATCH 34/39] resolve recursive references in function parameter types --- library/src/schemas/recursive/_recursive.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 91eb4a73f..3dd6ce88d 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -209,7 +209,9 @@ type ResolveRecursiveContainer = ? ResolveRecursiveArray : [RegisteredGeneric] extends [never] ? TValue extends (...args: infer TArgs) => infer TReturn - ? (...args: TArgs) => ResolveRecursiveValue + ? ( + ...args: ResolveRecursiveTuple + ) => ResolveRecursiveValue : TValue extends object ? { [TKey in keyof TValue]: ResolveRecursiveValue< @@ -307,8 +309,10 @@ export type RecursiveReferences = : TValue extends readonly (infer TItem)[] ? RecursiveReferences : [RegisteredGeneric] extends [never] - ? TValue extends (...args: never[]) => infer TReturn - ? RecursiveReferences + ? TValue extends (...args: infer TArgs) => infer TReturn + ? + | RecursiveReferences + | RecursiveReferences : TValue extends object ? { [TKey in keyof TValue]-?: RecursiveReferences< From cda0ff7d432e3ad15ee22e4387e84e72797745b6 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 00:47:10 +0100 Subject: [PATCH 35/39] resolve nested union issues in recursive schemas --- .../mutualRecursive/_mutualRecursive.ts | 5 ++- library/src/schemas/recursive/_recursive.ts | 41 ++++++++++++++++++- .../src/schemas/recursive/recursive.test-d.ts | 5 ++- .../src/schemas/recursive/recursive.test.ts | 17 ++++++++ library/src/schemas/recursive/recursive.ts | 3 +- .../src/schemas/recursive/recursiveAsync.ts | 3 +- 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/library/src/schemas/mutualRecursive/_mutualRecursive.ts b/library/src/schemas/mutualRecursive/_mutualRecursive.ts index e21031239..9f6f824f4 100644 --- a/library/src/schemas/mutualRecursive/_mutualRecursive.ts +++ b/library/src/schemas/mutualRecursive/_mutualRecursive.ts @@ -16,6 +16,7 @@ import { type Ref, type RefAsync, type ResolveRecursiveInput, + type ResolveRecursiveIssue, type ResolveRecursiveOutput, } from '../recursive/_recursive.ts'; import type { MutualRecursiveSchema } from './mutualRecursive.ts'; @@ -167,7 +168,7 @@ export interface ResolvedRecursiveSchema< TWrapped, ResolveRecursiveInput, TInputRoot>, ResolveRecursiveOutput, TOutputRoot>, - TIssue + ResolveRecursiveIssue > {} /** @@ -183,7 +184,7 @@ export interface ResolvedRecursiveSchemaAsync< TWrapped, ResolveRecursiveInput, TInputRoot>, ResolveRecursiveOutput, TOutputRoot>, - TIssue + ResolveRecursiveIssue > {} /** diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 3dd6ce88d..fd91cbc79 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -13,6 +13,7 @@ import type { import { _standardSchema } from '../../utils/_standardSchema/index.ts'; import type { MutualRecursiveSchema } from '../mutualRecursive/mutualRecursive.ts'; import type { MutualRecursiveSchemaAsync } from '../mutualRecursive/mutualRecursiveAsync.ts'; +import type { UnionIssue } from '../union/types.ts'; import type { RecursiveSchema } from './recursive.ts'; import type { RecursiveSchemaAsync } from './recursiveAsync.ts'; @@ -274,6 +275,38 @@ export type ResolveRecursiveOutput< type SyncOrAsyncSchema = GenericSchema | GenericSchemaAsync; +/** + * Recursive issue marker interface. + * + * @internal + */ +interface RecursiveIssueMarker + extends BaseIssue, + RecursiveMarker {} + +// A named interface defers recursive subissues until TypeScript needs them. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- This interface provides recursive issue indirection. +interface RecursiveUnionIssue, TRoot> + extends UnionIssue> {} + +/** + * Resolves recursive references in issue types. + * + * @internal + */ +export type ResolveRecursiveIssue = + TIssue extends RecursiveIssueMarker + ? TName extends undefined + ? [TIssue] extends [TSeen] + ? never + : ResolveRecursiveIssue + : never + : TIssue extends UnionIssue + ? RecursiveUnionIssue + : TIssue extends BaseIssue + ? TIssue + : never; + type Equal = B extends unknown ? [A] extends [B] ? [B] extends [A] @@ -501,7 +534,11 @@ export interface RecursiveMarker { * Recursive reference interface. */ export interface Ref - extends BaseSchema, RecursiveMarker, never> { + extends BaseSchema< + RecursiveMarker, + RecursiveMarker, + RecursiveIssueMarker + > { /** * The schema type. */ @@ -523,7 +560,7 @@ export interface RefAsync extends BaseSchemaAsync< RecursiveMarker, RecursiveMarker, - never + RecursiveIssueMarker > { /** * The schema type. diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index bb074d68a..013603f2e 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -258,8 +258,11 @@ describe('recursive', () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface represents recursive union subissues. + interface JsonUnionIssue + extends UnionIssue {} expectTypeOf>().toEqualTypeOf< - StringIssue | ArrayIssue | UnionIssue + StringIssue | ArrayIssue | JsonUnionIssue >(); }); diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index c297ec502..7eac1ffd0 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -12,6 +12,23 @@ import type { Ref } from './_recursive.ts'; import { recursive, type RecursiveSchema } from './recursive.ts'; describe('recursive', () => { + test('includes nested union issues from recursive references', () => { + const schema = recursive((self) => v.union([string(), array(self)])); + const result = v.safeParse(schema, [false]); + expect(result.issues).toMatchObject([ + { + type: 'union', + issues: [ + { type: 'string' }, + { + type: 'union', + issues: [{ type: 'string' }, { type: 'array' }], + }, + ], + }, + ]); + }); + test('should return schema object', () => { const definition = vi.fn((self: Ref) => object({ diff --git a/library/src/schemas/recursive/recursive.ts b/library/src/schemas/recursive/recursive.ts index 51c7355fa..24b52774b 100644 --- a/library/src/schemas/recursive/recursive.ts +++ b/library/src/schemas/recursive/recursive.ts @@ -10,6 +10,7 @@ import { createRecursive } from './_createRecursive.ts'; import { type Ref, type ResolveRecursiveInput, + type ResolveRecursiveIssue, type ResolveRecursiveOutput, } from './_recursive.ts'; @@ -53,7 +54,7 @@ export interface ResolvedRecursiveSchema< TWrapped, ResolveRecursiveInput, TInputRoot>, ResolveRecursiveOutput, TOutputRoot>, - TIssue + ResolveRecursiveIssue > {} /** diff --git a/library/src/schemas/recursive/recursiveAsync.ts b/library/src/schemas/recursive/recursiveAsync.ts index abd4e1291..03e260009 100644 --- a/library/src/schemas/recursive/recursiveAsync.ts +++ b/library/src/schemas/recursive/recursiveAsync.ts @@ -11,6 +11,7 @@ import { createRecursive } from './_createRecursive.ts'; import { type RefAsync, type ResolveRecursiveInput, + type ResolveRecursiveIssue, type ResolveRecursiveOutput, } from './_recursive.ts'; @@ -55,7 +56,7 @@ export interface ResolvedRecursiveSchemaAsync< TWrapped, ResolveRecursiveInput, TInputRoot>, ResolveRecursiveOutput, TOutputRoot>, - TIssue + ResolveRecursiveIssue > {} /** From 79b7d559a55cd68c6af5a7636e1b9ad9ab48a5a1 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 00:51:26 +0100 Subject: [PATCH 36/39] resolve named references in mutual union subissues --- .../mutualRecursive/_mutualRecursive.ts | 18 ++++++++++++++---- .../mutualRecursive/mutualRecursive.test-d.ts | 7 +++---- .../mutualRecursiveAsync.test-d.ts | 7 +++---- library/src/schemas/recursive/_recursive.ts | 12 +++++++----- .../src/schemas/recursive/recursive.test.ts | 13 +++++++++++++ 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/library/src/schemas/mutualRecursive/_mutualRecursive.ts b/library/src/schemas/mutualRecursive/_mutualRecursive.ts index 9f6f824f4..e64ed9b38 100644 --- a/library/src/schemas/mutualRecursive/_mutualRecursive.ts +++ b/library/src/schemas/mutualRecursive/_mutualRecursive.ts @@ -129,6 +129,12 @@ type EntryOutputs = { >; }; +type EntryIssues = { + [TKey in keyof TEntries as `${TKey & (string | number)}`]: InferIssue< + TEntries[TKey] + >; +}; + type ReferenceNames = TReference extends RecursiveMarker ? TName : never; @@ -164,11 +170,12 @@ export interface ResolvedRecursiveSchema< TInputRoot, TOutputRoot, TIssue extends BaseIssue, + TIssueRoot = TIssue, > extends MutualRecursiveSchema< TWrapped, ResolveRecursiveInput, TInputRoot>, ResolveRecursiveOutput, TOutputRoot>, - ResolveRecursiveIssue + ResolveRecursiveIssue > {} /** @@ -180,11 +187,12 @@ export interface ResolvedRecursiveSchemaAsync< TInputRoot, TOutputRoot, TIssue extends BaseIssue, + TIssueRoot = TIssue, > extends MutualRecursiveSchemaAsync< TWrapped, ResolveRecursiveInput, TInputRoot>, ResolveRecursiveOutput, TOutputRoot>, - ResolveRecursiveIssue + ResolveRecursiveIssue > {} /** @@ -197,7 +205,8 @@ export type RecursiveSchemas> = { TEntries[TName], EntryInputs, EntryOutputs, - InferIssue + InferIssue, + EntryIssues >; }; @@ -211,6 +220,7 @@ export type RecursiveSchemasAsync = { TEntries[TName], EntryInputs, EntryOutputs, - InferIssue + InferIssue, + EntryIssues >; }; diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts b/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts index f8b56c89d..ee348cc3d 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts @@ -271,10 +271,9 @@ describe('mutualRecursive', () => { })); type A = string | B[]; type B = string | A[]; - type Issues = - | StringIssue - | ArrayIssue - | UnionIssue; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface represents recursive union subissues. + interface RecursiveUnionIssue extends UnionIssue {} + type Issues = StringIssue | ArrayIssue | RecursiveUnionIssue; expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); diff --git a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts index b1895c765..1667836cb 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursiveAsync.test-d.ts @@ -275,10 +275,9 @@ describe('mutualRecursiveAsync', () => { })); type A = string | B[]; type B = string | A[]; - type Issues = - | StringIssue - | ArrayIssue - | UnionIssue; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- A named interface represents recursive union subissues. + interface RecursiveUnionIssue extends UnionIssue {} + type Issues = StringIssue | ArrayIssue | RecursiveUnionIssue; expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index fd91cbc79..7cd319835 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -296,11 +296,13 @@ interface RecursiveUnionIssue, TRoot> */ export type ResolveRecursiveIssue = TIssue extends RecursiveIssueMarker - ? TName extends undefined - ? [TIssue] extends [TSeen] - ? never - : ResolveRecursiveIssue - : never + ? [TIssue] extends [TSeen] + ? never + : TName extends undefined + ? ResolveRecursiveIssue + : TName extends keyof TRoot + ? ResolveRecursiveIssue + : never : TIssue extends UnionIssue ? RecursiveUnionIssue : TIssue extends BaseIssue diff --git a/library/src/schemas/recursive/recursive.test.ts b/library/src/schemas/recursive/recursive.test.ts index 7eac1ffd0..5adf465d4 100644 --- a/library/src/schemas/recursive/recursive.test.ts +++ b/library/src/schemas/recursive/recursive.test.ts @@ -12,6 +12,19 @@ import type { Ref } from './_recursive.ts'; import { recursive, type RecursiveSchema } from './recursive.ts'; describe('recursive', () => { + test('includes referenced issues in mutual union subissues', () => { + const group = v.mutualRecursive((ref) => ({ + a: v.union([string(), ref('b')]), + b: v.number(), + })); + expect(v.safeParse(group.a, false).issues).toMatchObject([ + { + type: 'union', + issues: [{ type: 'string' }, { type: 'number' }], + }, + ]); + }); + test('includes nested union issues from recursive references', () => { const schema = recursive((self) => v.union([string(), array(self)])); const result = v.safeParse(schema, [false]); From 06c3da0282b25ce544b6b2bb8d169ec02ccfb013 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 01:05:23 +0100 Subject: [PATCH 37/39] traverse recursive map keys and values independently --- .../mutualRecursive/mutualRecursive.test-d.ts | 39 +++++++++++++++++++ library/src/schemas/recursive/_recursive.ts | 4 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts b/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts index ee348cc3d..b3e1bf6cc 100644 --- a/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts +++ b/library/src/schemas/mutualRecursive/mutualRecursive.test-d.ts @@ -25,6 +25,7 @@ import { string, type StringIssue } from '../string/index.ts'; import { tuple } from '../tuple/index.ts'; import { tupleWithRest } from '../tupleWithRest/index.ts'; import { union, type UnionIssue } from '../union/index.ts'; +import { unknown } from '../unknown/index.ts'; import { variant } from '../variant/index.ts'; import { mutualRecursive, @@ -37,6 +38,26 @@ import { } from './mutualRecursive.ts'; describe('mutualRecursive', () => { + test('resolves recursive map values beside unknown keys', () => { + const group = mutualRecursive((ref) => ({ + node: object({ + name: string(), + children: map(unknown(), ref('node')), + }), + })); + interface Node { + name: string; + children: Map; + } + + expectTypeOf(group.node) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + const definition = (ref: RefFactory) => ({ a: object({ name: string(), bs: array(ref('b')) }), b: object({ score: number(), parent: optional(ref('a')) }), @@ -444,6 +465,24 @@ describe('mutualRecursive', () => { >().toEqualTypeOf>(); }); + test('collects map value references beside unknown and any keys', () => { + type Reference = RecursiveMarker<'value'>; + expectTypeOf< + RecursiveReferences> + >().toEqualTypeOf(); + expectTypeOf< + RecursiveReferences> + >().toEqualTypeOf(); + expectTypeOf< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + RecursiveReferences> + >().toEqualTypeOf(); + expectTypeOf< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Regression coverage requires any. + RecursiveReferences> + >().toEqualTypeOf(); + }); + test('collects references through containers and cyclic objects', () => { interface Cycle { next?: Cycle; diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 7cd319835..8ad3a091f 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -335,7 +335,9 @@ export type RecursiveReferences = : TValue extends | Map | ReadonlyMap - ? RecursiveReferences + ? + | RecursiveReferences + | RecursiveReferences : TValue extends | Set | ReadonlySet From e12d9140ef55792c4c7b76640450b63a612bbcd3 Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 01:58:30 +0100 Subject: [PATCH 38/39] preserve recursive references beside unknown and any generic arguments --- library/src/schemas/recursive/_recursive.ts | 7 +- .../recursive/recursive.generics.test-d.ts | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 8ad3a091f..68a1aae77 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -361,7 +361,12 @@ export type RecursiveReferences = : RegisteredGeneric extends { readonly args: infer TArgs extends readonly unknown[]; } - ? RecursiveReferences + ? { + [TKey in keyof TArgs]: RecursiveReferences< + TArgs[TKey], + TSeen | TValue + >; + }[number] : never; /** diff --git a/library/src/schemas/recursive/recursive.generics.test-d.ts b/library/src/schemas/recursive/recursive.generics.test-d.ts index 332bdf939..341cb9142 100644 --- a/library/src/schemas/recursive/recursive.generics.test-d.ts +++ b/library/src/schemas/recursive/recursive.generics.test-d.ts @@ -1,5 +1,16 @@ import { describe, expectTypeOf, test } from 'vitest'; import * as v from '../../index.ts'; +import type { + RecursiveMarker, + RecursiveReferences, + ResolveRecursiveInput, + ResolveRecursiveOutput, +} from './_recursive.ts'; + +interface Pair { + readonly first: TFirst; + readonly second: TSecond; +} interface Box { readonly kind: 'box'; @@ -14,6 +25,10 @@ declare module '../../types/generics.ts' { ? WeakMap : never; }; + Pair: { + getArgs: T extends Pair ? [A, B] : never; + applyArgs: TArgs extends [infer A, infer B] ? Pair : never; + }; Box: { getArgs: T extends Box ? [V] : never; applyArgs: TArgs extends [infer V] ? Box : never; @@ -32,6 +47,70 @@ interface OutputNode { } describe('registered recursive generics', () => { + test('resolves recursive children wrapped with unknown metadata', () => { + const schema = v.recursive((self) => + v.pipe( + v.object({ + name: v.string(), + child: v.optional(self), + metadata: v.unknown(), + }), + v.transform((node) => ({ + name: node.name, + child: { + first: node.metadata, + second: node.child, + } satisfies Pair, + })) + ) + ); + interface Node { + name: string; + child: Pair; + } + + expectTypeOf(schema) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toEqualTypeOf(); + }); + + test('preserves references beside unknown generic arguments', () => { + type Marker = RecursiveMarker<'node'>; + type Value = Pair; + interface Root { + node: { name: string }; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + Pair + >(); + expectTypeOf>().toEqualTypeOf< + Pair + >(); + }); + + test('preserves references beside any generic arguments', () => { + type Marker = RecursiveMarker<'node'>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reproduces reference erasure beside an any argument. + type Value = Pair; + interface Root { + node: { name: string }; + } + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The resolved input must preserve the any argument. + Pair + >(); + expectTypeOf>().toEqualTypeOf< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The resolved output must preserve the any argument. + Pair + >(); + }); + test('extracts and applies generic arguments', () => { expectTypeOf< v.Generics, never>['WeakMap']['getArgs'] From 00a7fd0ef1861d0ceb77392f28734b00025304ae Mon Sep 17 00:00:00 2001 From: James Tindal Date: Tue, 10 Nov 2026 02:45:58 +0100 Subject: [PATCH 39/39] preserve callable properties during recursive type resolution --- library/src/schemas/recursive/_recursive.ts | 18 ++++- .../src/schemas/recursive/recursive.test-d.ts | 73 ++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/library/src/schemas/recursive/_recursive.ts b/library/src/schemas/recursive/_recursive.ts index 68a1aae77..c5cc4f736 100644 --- a/library/src/schemas/recursive/_recursive.ts +++ b/library/src/schemas/recursive/_recursive.ts @@ -210,9 +210,17 @@ type ResolveRecursiveContainer = ? ResolveRecursiveArray : [RegisteredGeneric] extends [never] ? TValue extends (...args: infer TArgs) => infer TReturn - ? ( + ? (( ...args: ResolveRecursiveTuple - ) => ResolveRecursiveValue + ) => ResolveRecursiveValue) & + (keyof TValue extends never + ? unknown + : { + [TKey in keyof TValue]: ResolveRecursiveValue< + TValue[TKey], + TRoot + >; + }) : TValue extends object ? { [TKey in keyof TValue]: ResolveRecursiveValue< @@ -350,6 +358,12 @@ export type RecursiveReferences = ? | RecursiveReferences | RecursiveReferences + | { + [TKey in keyof TValue]-?: RecursiveReferences< + TValue[TKey], + TSeen | TValue + >; + }[keyof TValue] : TValue extends object ? { [TKey in keyof TValue]-?: RecursiveReferences< diff --git a/library/src/schemas/recursive/recursive.test-d.ts b/library/src/schemas/recursive/recursive.test-d.ts index 013603f2e..a42136016 100644 --- a/library/src/schemas/recursive/recursive.test-d.ts +++ b/library/src/schemas/recursive/recursive.test-d.ts @@ -27,10 +27,81 @@ import { tuple } from '../tuple/index.ts'; import { tupleWithRest } from '../tupleWithRest/index.ts'; import { union, type UnionIssue, type UnionSchema } from '../union/index.ts'; import { variant } from '../variant/index.ts'; -import type { Ref } from './_recursive.ts'; +import type { + RecursiveMarker, + RecursiveReferences, + Ref, + ResolveRecursiveInput, + ResolveRecursiveOutput, +} from './_recursive.ts'; import { recursive, type RecursiveSchema } from './recursive.ts'; describe('recursive', () => { + test('preserves attached function properties in transformed schema output', () => { + const schema = recursive((self) => + pipe( + object({ label: string(), children: array(self) }), + transform((node) => + Object.assign((index: number) => node.children[index], { + label: node.label, + children: node.children, + }) + ) + ) + ); + type Output = InferOutput; + + expectTypeOf(schema) + .toHaveProperty('~types') + .exclude() + .toHaveProperty('output') + .toHaveProperty('label') + .toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + test('resolves references used only in callable properties', () => { + type Marker = RecursiveMarker<'node'>; + type Callable = (() => void) & { extra: Marker }; + interface Root { + node: { name: string }; + } + type Expected = (() => void) & { extra: Root['node'] }; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + ResolveRecursiveInput + >().toEqualTypeOf(); + expectTypeOf< + ResolveRecursiveOutput + >().toEqualTypeOf(); + }); + + test('preserves callable properties while resolving the signature', () => { + type Marker = RecursiveMarker<'node'>; + type Callable = ((value: Marker) => Marker) & { + readonly extra: Marker; + optional?: Marker; + }; + interface Root { + node: { name: string }; + } + type Expected = ((value: Root['node']) => Root['node']) & { + readonly extra: Root['node']; + optional?: Root['node']; + }; + type Output = ResolveRecursiveOutput; + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + ResolveRecursiveInput + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<[Root['node']]>(); + expectTypeOf().not.toExtend<(value: Marker) => Marker>(); + }); + test('rejects definition maps and callable self references', () => { // @ts-expect-error Mutual recursion belongs to mutualRecursive. recursive(() => ({ a: string() }));