Embedded validation

The generated NomosFormats object holds the reconstructed template and a validator built from it. Each model's validate checks JSON against that embedded schema, then parses — no template file is needed at runtime. Fields typed by $ref are validated against the referenced definition, but $gen: and $extern: fields are not re-checked here: their validity is delegated to the referenced generated decode or the registered codec.

import com.example.models.user.User

User.validate("""{"id":"1","username":"jo","email":"jo@x.com","roles":["admin"]}""") match {
  case Right(user)  => // a validated User
  case Left(errors) => errors.foreach(e => println(e.fullMessage))
}

Validation targets a definition by its fully-qualified name internally (e.g. com.example.models.user.User), so models in different sub-packages never collide.

Validation errors

Failures return a List[ValidationError]; each carries the JSON path, message, expected, and actual.

case class ValidationError(path: String, message: String, expected: String, actual: String) {
  def fullMessage: String  // "path: message (expected: …, got: …)"
}

Schema checks include: required vs. optional fields, type mismatches, string/number/array constraints (including uniqueBy field uniqueness), cross-field presence groups ($oneOf/$atLeastOne), enum membership, whole-number checks for int/long, temporal parsing for date/datetime, discriminator resolution, and unexpected extra keys (unless $additionalProperties allows them). Numeric magnitudes outside the runtime's exact BigDecimal representation are rejected rather than bypassing constraints.

Validation is depth-bounded — recursion is capped to guard against pathological or deeply nested input — and constraint pattern regexes are compiled once and cached.

Validating with the API

You can also validate without generated code by parsing a template directly.

import dev.cjfravel.nomos.Nomos

val template = Nomos.parseTemplate(templateJson, "com.example").right.get
Nomos.validate(template, jsonData, "com.example.User") match {
  case Right(_)      => // valid
  case Left(errors)  => // List[ValidationError]
}

Custom validators

Schema describes shape; anything beyond it is a custom validator — an arbitrary check that receives a ValidatorContext and returns errors. Declare named validators on a definition, and register their implementations once at startup. Common uses include rules that span several fields or depend on one another (e.g. startDate <= endDate, or "if A then B is required"), but a validator can run any logic you like.

{
  "name": "Reservation",
  "validators": ["dates.startBeforeEnd"],
  "template": { "startDate": "string", "endDate": "string" }
}
import dev.cjfravel.nomos.Nomos
import dev.cjfravel.nomos.validation.ValidationError

Nomos.validators.register("dates.startBeforeEnd") { ctx =>
  def str(name: String) = ctx.node.asObject.flatMap(_.field(name)).flatMap(_.asString)
  if (str("startDate").zip(str("endDate")).forall { case (s, e) => s <= e }) Nil
  else List(ValidationError(ctx.path, "must be >= startDate", "ordered", "unordered"))
}

The ValidatorContext carries three things:

FieldMeaning
nodeThe JSON value at this validator's definition level — the whole document for a top-level definition, or the referenced sub-node when the definition is reached through a $ref.
rootThe whole document, so a validator running at a nested level can still traverse up to siblings or the root.
pathThe JSON path to node (e.g. root, root.child, root.items[2]), for error paths.

Custom validators run only after the whole document passes structural validation, so a validator never sees structurally invalid data — if any field anywhere is the wrong shape, no custom validator runs. A definition's validators run wherever that definition appears — including every place it is reached through a $ref (inside objects, arrays, maps, and discriminator variants) — not only when it is the top-level definition being validated. Errors are tagged with the validator name. For a top-level definition, node and root are the same value.

Custom string formats

The format constraint is backed by a registry seeded with named formats — email, url, uuid/guid (with case-restricted *Upper/*Lower variants), iso8601, the alpha/alphanumeric family, case-style formats (pascalCase, camelCase, snakeCase, kebabCase, screamingSnakeCase), and majorAndMinor; see Template Format for the full list. Add your own domain formats. An unrecognized format name is treated as valid — it doesn't fail validation.

Nomos.formats.register("hexColor")(_.matches("^#[0-9a-fA-F]{6}$"))
{ "color": { "type": "string", "format": "hexColor" } }

(De)serialization adapters

Per-field adapters convert between an on-the-wire form and your model's form — useful for legacy encodings. Adapters are honored only on required string fields; an adapter on a non-string field is rejected, and optional fields are not adapted. An unregistered adapter fails closed — decoding raises an error rather than passing the value through, so a missing registration surfaces immediately.

{ "createdAt": { "type": "string", "adapter": "epochMillis" } }
Nomos.adapters.register("epochMillis")(
  decode = wire => wire,   // String => String
  encode = model => model
)

Next: Code Generation — exactly what Nomos emits and how to configure it.