Validation
Nomos embeds the template into the generated code, so every model can validate JSON at runtime — and you can extend validation with named, registered hooks.
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:
| Field | Meaning |
|---|---|
node | The 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. |
root | The whole document, so a validator running at a nested level can still traverse up to siblings or the root. |
path | The 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.