Skip to content

Nested Object, Array, and Object-Bag Contracts ​

This library now supports the three nested contract shapes that come up constantly in shared REST payloads, without turning into a generic schema engine:

  1. Nested object fields with type: 'object' and schema
  2. Nested array items with type: 'array' and items
  3. Opaque object bags with type: 'object' and additionalProperties: true

The important design rule is that these are still application contracts, not arbitrary JSON Schema fragments.

Nested object fields ​

Use a child Schema instance when a field should itself be validated as an object.

javascript
const workspaceSummarySchema = createSchema({
  id: { type: 'id', required: true },
  slug: { type: 'string', required: true },
  ownerUserId: { type: 'id', required: true }
})

const workspaceSettingsSchema = createSchema({
  invitesEnabled: { type: 'boolean', required: true }
})

const workspaceViewSchema = createSchema({
  workspace: {
    type: 'object',
    required: true,
    schema: workspaceSummarySchema
  },
  settings: {
    type: 'object',
    required: true,
    schema: workspaceSettingsSchema
  }
})

How nested object fields behave:

  • The nested schema inherits the parent operation contract.
  • create() on the parent runs create-style rules inside the child.
  • patch() on the parent runs patch-style rules inside the child.
  • Errors are reported with dotted paths such as workspace.slug.
  • Unknown nested keys are rejected because child schemas are strict by default, just like top-level schemas.

That operation inheritance is deliberate. A nested object inside a patch payload is usually itself a patch payload.

Worked nested object example ​

Using the schema above:

javascript
const result = workspaceViewSchema.create({
  workspace: {
    id: '42',
    slug: '  main-workspace  ',
    extra: true
  },
  settings: {}
})

validatedObject becomes:

javascript
{
  workspace: {
    id: 42,
    slug: 'main-workspace'
  },
  settings: {}
}

errors becomes:

javascript
{
  'workspace.ownerUserId': {
    field: 'workspace.ownerUserId',
    code: 'REQUIRED',
    message: 'Field is required',
    params: {}
  },
  'workspace.extra': {
    field: 'workspace.extra',
    code: 'FIELD_NOT_ALLOWED',
    message: 'Field not allowed',
    params: {}
  },
  'settings.invitesEnabled': {
    field: 'settings.invitesEnabled',
    code: 'REQUIRED',
    message: 'Field is required',
    params: {}
  }
}

Now compare that with a nested patch:

javascript
workspaceViewSchema.patch({
  workspace: {
    slug: '  sandbox  '
  }
})

Result:

javascript
{
  validatedObject: {
    workspace: {
      slug: 'sandbox'
    }
  },
  errors: {}
}

Notice what did not happen:

  • workspace.id was not required
  • workspace.ownerUserId was not required
  • no defaults were invented

That is exactly because the child object inherited the parent patch contract.

Nested array items ​

Use items when every array entry should be validated recursively.

javascript
const roleSchema = createSchema({
  id: { type: 'string', required: true },
  label: { type: 'string', required: true }
})

const roleCatalogSchema = createSchema({
  roles: {
    type: 'array',
    required: true,
    items: roleSchema
  },
  assignableRoleIds: {
    type: 'array',
    required: true,
    items: { type: 'string', minLength: 1 }
  }
})

How array items behave:

  • Primitive item definitions are validated item-by-item and normalized in place.
  • If items is a nested object schema, each item is validated in replace mode.
  • Array item errors use indexed dotted paths such as roles.0.label.

That replace rule for object items is intentional. If a client sends the roles array in a patch, they are replacing the array field, so each object item still needs to be complete.

Worked nested array example ​

javascript
const result = roleCatalogSchema.patch({
  roles: [
    { id: 'admin' },
    { id: 'editor', label: '  Editor  ' }
  ],
  assignableRoleIds: [' owner ', '   ', 123]
})

validatedObject becomes:

javascript
{
  roles: [
    { id: 'admin' },
    { id: 'editor', label: 'Editor' }
  ],
  assignableRoleIds: ['owner', '', '123']
}

errors becomes:

javascript
{
  'roles.0.label': {
    field: 'roles.0.label',
    code: 'REQUIRED',
    message: 'Field is required',
    params: {}
  },
  'assignableRoleIds.1': {
    field: 'assignableRoleIds.1',
    code: 'MIN_LENGTH',
    message: 'Length must be at least 1 characters.',
    params: { min: 1, actual: 0 }
  }
}

This example shows both supported array styles:

  • roles uses a child Schema instance for structured object items
  • assignableRoleIds uses an inline field definition for primitive items

Opaque object bags ​

If a field needs to be “some object, but not one this library owns”, make that explicit:

javascript
const schema = createSchema({
  metadata: {
    type: 'object',
    additionalProperties: true
  }
})

That means:

  • the value must be a plain object
  • keys are not validated
  • values pass through untouched

This is the intended escape hatch for metadata bags and adapter-owned payloads. It is deliberately narrow: additionalProperties only supports the literal value true. You can combine it with schema when you want to validate known child fields while still allowing arbitrary passthrough keys.

GPL-3.0-only