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