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:
- Nested object fields with
type: 'object'andschema - Nested array items with
type: 'array'anditems - Opaque object bags with
type: 'object'andadditionalProperties: 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.
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 runscreate-style rules inside the child.patch()on the parent runspatch-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:
const result = workspaceViewSchema.create({
workspace: {
id: '42',
slug: ' main-workspace ',
extra: true
},
settings: {}
})validatedObject becomes:
{
workspace: {
id: 42,
slug: 'main-workspace'
},
settings: {}
}errors becomes:
{
'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:
workspaceViewSchema.patch({
workspace: {
slug: ' sandbox '
}
})Result:
{
validatedObject: {
workspace: {
slug: 'sandbox'
}
},
errors: {}
}Notice what did not happen:
workspace.idwas not requiredworkspace.ownerUserIdwas 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.
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
itemsis a nested object schema, each item is validated inreplacemode. - 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
const result = roleCatalogSchema.patch({
roles: [
{ id: 'admin' },
{ id: 'editor', label: ' Editor ' }
],
assignableRoleIds: [' owner ', ' ', 123]
})validatedObject becomes:
{
roles: [
{ id: 'admin' },
{ id: 'editor', label: 'Editor' }
],
assignableRoleIds: ['owner', '', '123']
}errors becomes:
{
'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:
rolesuses a childSchemainstance for structured object itemsassignableRoleIdsuses 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:
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.