Skip to content

Recursive Schemas

Recursive schema graphs are supported at runtime.

The important distinction is that the library follows the graph of Schema instances, not just one level of nesting. That means a field or array item can point back to the same schema instance, and the runtime will keep validating deeper paths using the same operation rules it would use for any non-recursive nested contract.

The practical setup rule is simple: self-references are usually wired after the first createSchema(...) call, because the variable must exist before another field can point at it.

Self-recursive object and array example

javascript
const nodeSchema = createSchema({
  id: { type: 'string', required: true },
  label: { type: 'string', required: true },
  parent: { type: 'object', required: false },
  children: { type: 'array', required: false }
})

nodeSchema.structure.parent.schema = nodeSchema
nodeSchema.structure.children.items = nodeSchema

That creates two different recursive edges:

  • parent is a nested object field that points back to nodeSchema
  • children.items is an array of nodeSchema objects

Recursive runtime semantics

The same rules still apply inside the recursive graph:

  • nested object fields such as parent inherit the active operation contract
  • array items that are object schemas still use replace semantics
  • recursive errors stay in the same flat dotted-path shape as any other nested error

Example:

javascript
const patchParent = nodeSchema.patch({
  parent: {
    label: '  Root  '
  }
})

const patchChildren = nodeSchema.patch({
  children: [
    { label: 'Only child label' }
  ]
})

patchParent succeeds with:

javascript
{
  validatedObject: {
    parent: {
      label: 'Root'
    }
  },
  errors: {}
}

That happens because parent is a nested object field and inherits the outer patch operation.

patchChildren returns:

javascript
{
  validatedObject: {
    children: [
      {
        label: 'Only child label'
      }
    ]
  },
  errors: {
    'children.0.id': {
      field: 'children.0.id',
      code: 'REQUIRED',
      message: 'Field is required',
      params: {}
    }
  }
}

That happens because array items that point to object schemas are always treated as full replacements.

Recursive paths, introspection, and transport export

Recursive schemas keep the same dotted-path model everywhere else too:

  • nodeSchema.getFieldDefinition('children.0.label') resolves correctly
  • nodeSchema.validateAt('children.0.label', payload) validates only that selected path
  • recursive transport export is graph-aware rather than stack-recursive

toJsonSchema() uses draft-07 definitions plus $ref for recursive nested contracts, and direct self-recursive object fields point back to #. The transport-specific details are covered again in the Transport JSON Schema Export chapter below.

GPL-3.0-only