Skip to main content
Version: 2.x

JSON Schema

JsonSchema provides first-class support for JSON Schema 2020-12 in ZIO Blocks. It enables parsing, construction, validation, and serialization of JSON Schemas as native Scala values.

Overview​

The JsonSchema type is a sealed ADT representing all valid JSON Schema documents:

JsonSchema
├── JsonSchema.True (accepts all values - equivalent to {})
├── JsonSchema.False (rejects all values - equivalent to {"not": {}})
└── JsonSchema.Object (full schema with all keywords)

Key features:

  • Full JSON Schema 2020-12 support - All standard vocabularies (core, applicator, validation, format, meta-data)
  • Type-safe construction - Smart constructors and builder pattern
  • Validation - Validate JSON values against schemas with detailed error messages
  • Round-trip serialization - Parse from JSON and serialize back without loss
  • Combinators - Compose schemas with && (allOf), || (anyOf), ! (not)
  • 817 of 844 official tests passing (97%+)

Deriving JSON Schema from Schema​

The most common use case is deriving a JSON Schema from an existing Schema[A].

Basic Derivation​

import zio.blocks.schema._
import zio.blocks.schema.json._

case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived
}

// Get JSON Schema directly from Schema
val jsonSchema: JsonSchema = Schema[Person].toJsonSchema

// The derived schema validates JSON values
val valid = Json.Object("name" -> Json.String("Alice"), "age" -> Json.Number(30))
val invalid = Json.Object("name" -> Json.Number(123))

jsonSchema.conforms(valid) // true
jsonSchema.conforms(invalid) // false

Through JsonBinaryCodec​

For more control, derive through JsonBinaryCodec:

import zio.blocks.schema._
import zio.blocks.schema.json._

case class User(email: String, active: Boolean)
object User {
implicit val schema: Schema[User] = Schema.derived
}

// Derive codec first, then get JSON Schema
val codec = Schema[User].derive(JsonFormat)
val jsonSchema = codec.toJsonSchema

Creating Schemas​

Boolean Schemas​

The simplest schemas accept or reject all values:

import zio.blocks.schema.json.JsonSchema

// Accepts any valid JSON value
val acceptAll = JsonSchema.True

// Rejects all JSON values
val rejectAll = JsonSchema.False

Type Schemas​

Create schemas that validate specific JSON types:

import zio.blocks.schema.json.{JsonSchema, JsonSchemaType}

// Single type
val stringSchema = JsonSchema.ofType(JsonSchemaType.String)
val numberSchema = JsonSchema.ofType(JsonSchemaType.Number)
val integerSchema = JsonSchema.ofType(JsonSchemaType.Integer)
val booleanSchema = JsonSchema.ofType(JsonSchemaType.Boolean)
val arraySchema = JsonSchema.ofType(JsonSchemaType.Array)
val objectSchema = JsonSchema.ofType(JsonSchemaType.Object)
val nullSchema = JsonSchema.ofType(JsonSchemaType.Null)

// Convenience aliases
val isNull = JsonSchema.nullSchema
val isBoolean = JsonSchema.boolean

String Schemas​

Create schemas for string validation:

import zio.blocks.schema.json.{JsonSchema, NonNegativeInt, RegexPattern}

// String with length constraints (compile-time validated literals)
val username = JsonSchema.string(
NonNegativeInt.literal(3),
NonNegativeInt.literal(20)
)

// String with pattern
val hexColor = JsonSchema.string(
pattern = RegexPattern.unsafe("^#[0-9a-fA-F]{6}$")
)

// String with format
val email = JsonSchema.string(format = Some("email"))
val dateTime = JsonSchema.string(format = Some("date-time"))
val uuid = JsonSchema.string(format = Some("uuid"))

Numeric Schemas​

Create schemas for number validation:

import zio.blocks.schema.json.{JsonSchema, PositiveNumber}

// Number with range
val percentage = JsonSchema.number(
minimum = Some(BigDecimal(0)),
maximum = Some(BigDecimal(100))
)

// Integer with exclusive bounds
val positiveInt = JsonSchema.integer(
exclusiveMinimum = Some(BigDecimal(0))
)

// Number divisible by a value
val evenNumber = JsonSchema.integer(
multipleOf = PositiveNumber.fromInt(2)
)

Array Schemas​

Create schemas for array validation:

import zio.blocks.chunk.NonEmptyChunk
import zio.blocks.schema.json.{JsonSchema, JsonSchemaType, NonNegativeInt}

// Array of strings
val stringArray = JsonSchema.array(
items = Some(JsonSchema.ofType(JsonSchemaType.String))
)

// Array with length constraints
val shortList = JsonSchema.array(
JsonSchema.ofType(JsonSchemaType.Number),
NonNegativeInt.literal(1),
NonNegativeInt.literal(5)
)

// Array with unique items
val uniqueNumbers = JsonSchema.array(
items = Some(JsonSchema.ofType(JsonSchemaType.Number)),
uniqueItems = Some(true)
)

// Tuple-like array with prefixItems
val point2D = JsonSchema.array(
prefixItems = Some(NonEmptyChunk(
JsonSchema.ofType(JsonSchemaType.Number),
JsonSchema.ofType(JsonSchemaType.Number)
))
)

Object Schemas​

Create schemas for object validation:

import zio.blocks.schema.json.{JsonSchema, JsonSchemaType}
import zio.blocks.chunk.ChunkMap

// Object with properties
val person = JsonSchema.obj(
properties = Some(ChunkMap(
"name" -> JsonSchema.ofType(JsonSchemaType.String),
"age" -> JsonSchema.ofType(JsonSchemaType.Integer)
)),
required = Some(Set("name"))
)

// Object with no additional properties
val strictPerson = JsonSchema.obj(
properties = Some(ChunkMap(
"name" -> JsonSchema.ofType(JsonSchemaType.String),
"age" -> JsonSchema.ofType(JsonSchemaType.Integer)
)),
required = Some(Set("name")),
additionalProperties = Some(JsonSchema.False)
)

Enum and Const​

import zio.blocks.chunk.NonEmptyChunk
import zio.blocks.schema.json.{JsonSchema, Json}

// Enum of string values
val status = JsonSchema.enumOfStrings(NonEmptyChunk("pending", "active", "completed"))

// Enum of mixed values
val mixed = JsonSchema.enumOf(NonEmptyChunk(
Json.String("auto"),
Json.Number(0),
Json.Boolean(true)
))

// Constant value
val alwaysTrue = JsonSchema.constOf(Json.Boolean(true))

Schema Combinators​

Logical Composition​

Combine schemas using logical operators:

import zio.blocks.schema.json.{JsonSchema, JsonSchemaType}

val stringSchema = JsonSchema.ofType(JsonSchemaType.String)
val numberSchema = JsonSchema.ofType(JsonSchemaType.Number)
val nullSchema = JsonSchema.ofType(JsonSchemaType.Null)

// allOf - must match all schemas
val stringAndNotEmpty = stringSchema && JsonSchema.string(
minLength = Some(zio.blocks.schema.json.NonNegativeInt.literal(1))
)

// anyOf - must match at least one schema
val stringOrNumber = stringSchema || numberSchema

// not - must not match the schema
val notNull = !nullSchema

// Combining operators
val nullableString = stringSchema || nullSchema

Nullable Schemas​

Make any schema nullable:

import zio.blocks.schema.json.{JsonSchema, JsonSchemaType}

val stringSchema = JsonSchema.ofType(JsonSchemaType.String)

// Accepts string or null
val nullableString = stringSchema.withNullable

Conditional Schemas​

if/then/else​

Apply different schemas based on conditions:

import zio.blocks.schema.json.{JsonSchema, JsonSchemaType, NonNegativeInt}

// If type is string, require minLength
val conditionalSchema = JsonSchema.Object(
`if` = Some(JsonSchema.ofType(JsonSchemaType.String)),
`then` = Some(JsonSchema.string(minLength = Some(NonNegativeInt.literal(1)))),
`else` = Some(JsonSchema.True)
)

Dependent Schemas​

Apply schemas when properties are present:

import zio.blocks.schema.json.{JsonSchema, JsonSchemaType}
import zio.blocks.chunk.ChunkMap

// If "credit_card" exists, require "billing_address"
val paymentSchema = JsonSchema.Object(
properties = Some(ChunkMap(
"credit_card" -> JsonSchema.ofType(JsonSchemaType.String),
"billing_address" -> JsonSchema.ofType(JsonSchemaType.String)
)),
dependentRequired = Some(ChunkMap(
"credit_card" -> Set("billing_address")
))
)

Validation​

Basic Validation​

import zio.blocks.schema.json.{JsonSchema, Json, JsonSchemaType}
import zio.blocks.chunk.ChunkMap

val schema = JsonSchema.obj(
properties = Some(ChunkMap(
"name" -> JsonSchema.ofType(JsonSchemaType.String),
"age" -> JsonSchema.integer(minimum = Some(BigDecimal(0)))
)),
required = Some(Set("name"))
)

val validJson = Json.Object(
"name" -> Json.String("Alice"),
"age" -> Json.Number(30)
)

val invalidJson = Json.Object(
"age" -> Json.Number(-5)
)

// Using check() - returns Option[SchemaError]
schema.check(validJson) // None (valid)
schema.check(invalidJson) // Some(SchemaError(...))

// Using conforms() - returns Boolean
schema.conforms(validJson) // true
schema.conforms(invalidJson) // false

Validation Options​

Control validation behavior:

import zio.blocks.schema.json.{JsonSchema, Json, ValidationOptions}

val schema = JsonSchema.string(format = Some("email"))
val value = Json.String("not-an-email")

// With format validation (default)
val strictOptions = ValidationOptions.formatAssertion
schema.check(value, strictOptions) // Some(error)

// Without format validation (format as annotation only)
val lenientOptions = ValidationOptions.annotationOnly
schema.check(value, lenientOptions) // None

Error Messages​

Validation errors include path information:

import zio.blocks.schema.json.{JsonSchema, Json, JsonSchemaType}
import zio.blocks.chunk.ChunkMap

val schema = JsonSchema.obj(
properties = Some(ChunkMap(
"users" -> JsonSchema.array(
items = Some(JsonSchema.obj(
properties = Some(ChunkMap(
"email" -> JsonSchema.string(format = Some("email"))
))
))
)
))
)

val invalid = Json.Object(
"users" -> Json.Array(
Json.Object("email" -> Json.String("invalid"))
)
)

schema.check(invalid) match {
case Some(err) => println(err.message)
// "String 'invalid' is not a valid email address"
case None => println("Valid")
}

Parsing and Serialization​

Parsing from JSON​

import zio.blocks.schema.json.{JsonSchema, Json}

// From JSON string
val parsed = JsonSchema.parse("""
{
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"]
}
""")

// From Json value
val json = Json.Object(
"type" -> Json.String("string"),
"minLength" -> Json.Number(1)
)
val fromJson = JsonSchema.fromJson(json)

Serializing to JSON​

import zio.blocks.schema.json.{JsonSchema, NonNegativeInt}

val schema = JsonSchema.string(
NonNegativeInt.literal(1),
NonNegativeInt.literal(100)
)

val json = schema.toJson
// {"type":"string","minLength":1,"maxLength":100}

val jsonString = json.print

Format Validation​

The following formats are supported for validation:

FormatDescriptionExample
date-timeRFC 3339 date-time2024-01-15T10:30:00Z
dateRFC 3339 full-date2024-01-15
timeRFC 3339 full-time10:30:00Z
emailEmail addressuser@example.com
uuidRFC 4122 UUID550e8400-e29b-41d4-a716-446655440000
uriRFC 3986 URIhttps://example.com/path
uri-referenceRFC 3986 URI-reference/path/to/resource
ipv4IPv4 address192.168.1.1
ipv6IPv6 address2001:db8::1
hostnameRFC 1123 hostnameexample.com
regexECMA-262 regex^[a-z]+$
durationISO 8601 durationP3Y6M4DT12H30M5S
json-pointerRFC 6901 JSON Pointer/foo/bar/0

Format validation is enabled by default. Use ValidationOptions.annotationOnly to treat format as annotation only (per JSON Schema spec).

Unevaluated Properties and Items​

JSON Schema 2020-12 introduces unevaluatedProperties and unevaluatedItems for validating properties/items not matched by any applicator keyword:

import zio.blocks.chunk.{ChunkMap, NonEmptyChunk}
import zio.blocks.schema.json.{JsonSchema, JsonSchemaType}

// Reject any properties not defined in properties or patternProperties
val strictObject = JsonSchema.Object(
properties = Some(ChunkMap(
"name" -> JsonSchema.ofType(JsonSchemaType.String)
)),
unevaluatedProperties = Some(JsonSchema.False)
)

// Reject extra array items not matched by prefixItems or items
val strictArray = JsonSchema.Object(
prefixItems = Some(NonEmptyChunk(
JsonSchema.ofType(JsonSchemaType.String),
JsonSchema.ofType(JsonSchemaType.Number)
)),
unevaluatedItems = Some(JsonSchema.False)
)

Schema Object Fields​

JsonSchema.Object supports all JSON Schema 2020-12 keywords:

Core Vocabulary​

  • $id, $schema, $anchor, $dynamicAnchor
  • $ref, $dynamicRef (limited support - see Limitations)
  • $defs, $comment

Applicator Vocabulary​

  • allOf, anyOf, oneOf, not
  • if, then, else
  • properties, patternProperties, additionalProperties
  • propertyNames, dependentSchemas
  • prefixItems, items, contains

Unevaluated Vocabulary​

  • unevaluatedProperties, unevaluatedItems

Validation Vocabulary​

  • type, enum, const
  • multipleOf, minimum, maximum, exclusiveMinimum, exclusiveMaximum
  • minLength, maxLength, pattern
  • minItems, maxItems, uniqueItems, minContains, maxContains
  • minProperties, maxProperties, required, dependentRequired

Format Vocabulary​

  • format

Content Vocabulary​

  • contentEncoding, contentMediaType, contentSchema

Meta-Data Vocabulary​

  • title, description, default, deprecated
  • readOnly, writeOnly, examples

Limitations​

Not Implemented​

The following features require reference resolution and are not supported:

FeatureDescription
$ref to external URIsReferences to other files or URLs
$dynamicRef / $dynamicAnchorDynamic reference resolution
$id resolutionBase URI changing and resolution
Remote referencesFetching schemas from URLs
Recursive schemas via $refSelf-referential schemas using references

Local $ref within the same schema is partially supported for #/$defs/... references only.

Known Edge Cases​

CaseBehavior
Float/integer numeric equality1.0 is not treated as equal to 1 for const/enum
String lengthMeasured in codepoints, not grapheme clusters
Some unevaluatedItems with containsEdge cases involving item evaluation tracking

Test Suite Compliance​

The implementation passes 817 of 844 tests (97%+) from the official JSON Schema Test Suite for draft2020-12. The remaining tests require reference resolution features listed above.

Complete Example​

import zio.blocks.chunk.{ChunkMap, NonEmptyChunk}
import zio.blocks.schema.json._

// Define a complex schema
val userSchema = JsonSchema.obj(
properties = Some(ChunkMap(
"id" -> JsonSchema.string(format = Some("uuid")),
"email" -> JsonSchema.string(format = Some("email")),
"name" -> JsonSchema.string(
NonNegativeInt.literal(1),
NonNegativeInt.literal(100)
),
"age" -> JsonSchema.integer(
minimum = Some(BigDecimal(0)),
maximum = Some(BigDecimal(150))
),
"roles" -> JsonSchema.array(
items = Some(JsonSchema.enumOfStrings(
NonEmptyChunk("admin", "user", "guest")
)),
minItems = Some(NonNegativeInt.literal(1)),
uniqueItems = Some(true)
),
"metadata" -> JsonSchema.obj(
additionalProperties = Some(JsonSchema.ofType(JsonSchemaType.String))
)
)),
required = Some(Set("id", "email", "name", "roles")),
additionalProperties = Some(JsonSchema.False)
)

// Validate some data
val validUser = Json.Object(
"id" -> Json.String("550e8400-e29b-41d4-a716-446655440000"),
"email" -> Json.String("alice@example.com"),
"name" -> Json.String("Alice"),
"roles" -> Json.Array(Json.String("admin"), Json.String("user"))
)

val invalidUser = Json.Object(
"id" -> Json.String("not-a-uuid"),
"email" -> Json.String("invalid-email"),
"name" -> Json.String(""),
"roles" -> Json.Array(),
"extra" -> Json.String("not allowed")
)

userSchema.conforms(validUser) // true
userSchema.conforms(invalidUser) // false

// Get detailed errors
userSchema.check(invalidUser) match {
case Some(err) => println(err.message)
case None => println("Valid!")
}

// Serialize the schema
val schemaJson = userSchema.toJson.print
// Can be sent to other tools, stored, or shared

Cross-Platform Support​

JsonSchema works across all platforms:

  • JVM - Full functionality
  • Scala.js - Browser and Node.js

All features work identically across platforms.