A comprehensive JSON Schema generator for ZIO Schema that converts Scala types to JSON Schema Draft 2020-12.
Cross-compiled for Scala 2.13.16 and Scala 3.7.3
- 🔄 Cross-Compilation - Full support for both Scala 2.13 and Scala 3
- 🎯 Type-Safe Annotations - Scala 3 context bounds preserve Schema information in annotations
- 🔧 Accurate Complex Type Encoding - Uses
zio-schema-jsonto properly encode Lists, Maps, and nested case classes - 📦 ZIO Ecosystem Integration - First-class support for zio-schema with automatic derivation
- 🔄 Schema Evolution Tracking - Built-in compatibility analysis for API versioning
- 📝 JSON Schema 2020-12 - Modern JSON Schema support with extensive validation features
- 🚀 Strict API Support - Built-in annotations for APIs like OpenAI that require strict schemas
- ✅ JSON Schema Draft 2020-12 support (~85-90% coverage)
- ✅ ZIO Schema Integration - Automatic schema derivation from case classes, enums, and sealed traits
- ✅ Extension Methods - Convenient syntax for generating JSON schemas
- ✅ Schema Evolution - Track changes between schema versions with compatibility analysis
- Primitives: String, Int, Long, Double, Float, Boolean, BigInt, BigDecimal
- Collections: List, Vector, Set, Chunk, Map
- Tuples: Tuple2, Tuple3, and beyond
- Optional Fields:
Option[T]types - Algebraic Data Types: Enums and sealed traits with discriminator support
- Either Types: Left/Right variant handling
- Recursive Types: Automatic detection with
$defsand$ref
@fieldName("custom_name")- Custom field naming (e.g., for snake_case APIs)@defaultValue(value)- Type-safe default values (Scala 3 only, recommended)@fieldDefaultValue(value)- Default values (type-erased, for Scala 2.13 compatibility)@transientField- Exclude fields from schema@requiredField- Force optional fields to appear in required array@description("text")- Add documentation to types and fields@readOnly- Mark fields as read-only@writeOnly- Mark fields as write-only@deprecated("message")- Mark fields as deprecated@exampleValues(values*)- Type-safe example values (Scala 3 only, recommended)@examples(values*)- Example values (type-erased, for Scala 2.13 compatibility)
@format("email" | "uri" | "date-time" | ...)- String format validation@stringEnum("val1", "val2", ...)- Enum string values@validate(Validation.minLength(n))- Minimum string length@validate(Validation.maxLength(n))- Maximum string length@validate(Validation.pattern(regex))- Pattern matching
@minimum(value)- Minimum numeric value@maximum(value)- Maximum numeric value@multipleOf(value)- Numeric multiple constraint
@minItems(n)- Minimum array length@maxItems(n)- Maximum array length
@minProperties(n)- Minimum number of properties@maxProperties(n)- Maximum number of properties@requireAll- Mark all fields (includingOptiontypes) as required with nullable types@additionalProperties(allowed)- Control whether extra properties are allowed (true/false)@strict- Combines@requireAlland@additionalProperties(false)for strict validation
@discriminatorName("field")- Custom discriminator field name@noDiscriminator- Disable discriminator for enums@anyOf()- UseanyOfinstead ofoneOffor sum types (useful for OpenAI compatibility)@allOf()- UseallOfinstead ofoneOffor sum types (intersection semantics)
Most features work identically across both Scala versions. The main differences:
- ✅ Type-Safe Annotations:
@defaultValueand@exampleValueswith proper type preservation - ✅ Enum syntax:
enum Color: case Red, Green, Blue - ✅ Extension methods: Natural
person.jsonSchemasyntax
- ✅ All core annotations work the same way
- ✅ Extension methods available via implicit conversions
- ✅ Use sealed traits instead of enums
⚠️ Use@fieldDefaultValueand@examples(type-erased) instead of type-safe versions⚠️ DeriveSchema.genmust be called at object/class level, not in function blocks
- ✅ All validation annotations (
@format,@minimum,@maximum, etc.) - ✅ Field annotations (
@fieldName,@readOnly,@writeOnly, etc.) - ✅ Object-level annotations (
@requireAll,@strict,@additionalProperties) - ✅ Schema evolution tracking
- ✅ Recursive type handling
- ✅ ADT/sealed trait support
Add to your build.sbt:
libraryDependencies += "io.github.russwyte" %% "schemanator" % "{version}"Scala 3:
import schemanator.*
import zio.schema.*
// Define your types
case class Person(
name: String,
age: Int,
email: Option[String]
) derives Schema
// Generate JSON Schema using extension methods
val person = Person("Alice", 30, Some("alice@example.com"))
// Get JSON Schema as AST
val jsonAst = person.jsonSchemaAst
// Get JSON Schema as compact string
val jsonString = person.jsonSchema
// Get JSON Schema as pretty-printed string
val prettyJson = person.jsonSchemaPrettyScala 2.13:
import schemanator._
import zio.schema._
// Define your types
case class Person(
name: String,
age: Int,
email: Option[String]
)
object Person {
implicit val schema: Schema[Person] = DeriveSchema.gen[Person]
}
// Extension methods work the same way via implicit conversions
val person = Person("Alice", 30, Some("alice@example.com"))
val jsonAst = person.jsonSchemaAst
val jsonString = person.jsonSchema
val prettyJson = person.jsonSchemaPretty// Generate schema directly from Schema type (both Scala 2.13 and 3)
val schema = Schema[Person]
val jsonSchema = schema.jsonSchemaPrettyimport zio.schema.annotation.*
case class ApiResponse(
@fieldName("user_name") userName: String,
@fieldName("user_id") userId: Int
) derives Schemaimport zio.schema.validation.Validation
import schemanator.annotations.*
case class Username(
@validate(Validation.minLength(3) && Validation.maxLength(20))
value: String
) derives Schema
case class Age(
@minimum(0.0) @maximum(120.0)
value: Int
) derives SchemaSchemanator provides type-safe annotations that properly encode complex types:
import schemanator.annotations.*
case class ServerConfig(
@defaultValue("localhost") host: String,
@defaultValue(8080) port: Int,
@defaultValue(List("http", "https")) protocols: List[String],
@exampleValues(30, 60, 120) timeoutSeconds: Int
) derives SchemaWhy use type-safe annotations?
- ✅ Complex types (Lists, Maps, case classes) are properly encoded as JSON
- ✅ Type checking at compile time
- ✅ Uses
zio-schema-jsonfor accurate encoding - ❌ Non-type-safe alternatives (
@fieldDefaultValue,@examples) use.toString()for complex types
// Type-safe (recommended)
@defaultValue(List(1, 2, 3)) numbers: List[Int]
// Result: "default": [1, 2, 3]
// Type-erased (compatibility only)
@fieldDefaultValue(List(1, 2, 3)) numbers: List[Int]
// Result: "default": "List(1, 2, 3)" // Not valid JSON!import schemanator.annotations.*
case class Contact(
@format("email") email: String,
@format("uri") website: String,
@stringEnum("active", "inactive", "pending") status: String
) derives SchemaMany APIs (like OpenAI, Anthropic, etc.) require strict schema validation. Schemanator provides convenient annotations for this:
import schemanator.annotations.*
// Use @strict for maximum strictness (recommended for most APIs)
@strict
case class OpenAIRequest(
name: String,
description: Option[String],
tags: Option[List[String]]
) derives Schema
// Generates:
// {
// "type": "object",
// "properties": {
// "name": { "type": "string" },
// "description": { "type": ["string", "null"] },
// "tags": { "type": ["array", "null"], "items": { "type": "string" } }
// },
// "required": ["name", "description", "tags"],
// "additionalProperties": false
// }Granular Control:
// Just require all fields (including Option types)
@requireAll
case class Config(
host: String,
port: Option[Int]
) derives Schema
// Just disallow additional properties
@additionalProperties(false)
case class StrictShape(
x: Int,
y: Int
) derives Schema
// Individual field control
case class MixedRequest(
name: String,
email: Option[String], // Optional, not required
@requiredField phone: Option[String] // Optional type, but required field
) derives Schemaimport zio.schema.annotation.*
@discriminatorName("type")
enum Vehicle derives Schema:
case Car(make: String, model: String)
case Bike(gears: Int)
// Generates oneOf with discriminator property
val schema = Schema[Vehicle]
println(schema.jsonSchemaPretty)OpenAI and some other APIs don't support oneOf but do support anyOf. The @anyOf annotation generates JSON Schema-compliant anyOf schemas:
import schemanator.annotations.*
// Use @anyOf to generate anyOf instead of oneOf
@anyOf()
sealed trait PaymentMethod derives Schema
case class CreditCard(cardNumber: String) extends PaymentMethod
case class BankTransfer(accountNumber: String) extends PaymentMethod
val schema = Schema[PaymentMethod]
println(schema.jsonSchemaPretty)
// Generates:
// {
// "anyOf": [
// { "type": "object", "properties": { "cardNumber": { "type": "string" } }, "required": ["cardNumber"] },
// { "type": "object", "properties": { "accountNumber": { "type": "string" } }, "required": ["accountNumber"] }
// ]
// }
// Also works with Either types
@anyOf()
type Result = Either[Error, Success]Important: @anyOf automatically disables discriminators, following JSON Schema semantics:
oneOf= "exactly one must match" → uses discriminators to identify which variantanyOf= "one or more can match" → no discriminator needed (per JSON Schema spec)
This makes @anyOf fully compatible with OpenAI's structured output requirements, which expect clean, standalone schemas without discriminators.
The @allOf annotation generates schemas with intersection semantics (all schemas must match):
import schemanator.annotations.*
// Use @allOf to express that a value must satisfy ALL alternatives
@allOf()
sealed trait ApiResponse derives Schema
case class SuccessResponse(data: String) extends ApiResponse
case class ErrorResponse(error: String) extends ApiResponse
val schema = Schema[ApiResponse]
println(schema.jsonSchemaPretty)
// Generates:
// {
// "allOf": [
// { "type": "object", "properties": { "data": { "type": "string" } } },
// { "type": "object", "properties": { "error": { "type": "string" } } }
// ]
// }Note: allOf represents intersection semantics where a value must validate against ALL schemas. This is less common for sum types but useful for expressing complex validation requirements.
case class Address(street: String, city: String, zipCode: String)
case class Company(
name: String,
address: Address,
employees: List[Person]
) derives Schema
// Schemas are automatically derived for nested types
val schema = Schema[Company]case class Tree(value: Int, children: List[Tree]) derives Schema
// Automatically generates $defs and $ref for recursive references
val schema = Schema[Tree]Track changes between schema versions and analyze compatibility:
import schemanator.evolution.*
case class PersonV1(name: String, age: Int) derives Schema
case class PersonV2(name: String, age: Int, email: Option[String]) derives Schema
val result = SchemaEvolution.compareSchemas(Schema[PersonV1], Schema[PersonV2])
result.changes.foreach(println)
// FieldAdded(email, ..., optional=true)
result.compatibility match
case CompatibilityType.FullyCompatible => println("Fully compatible!")
case CompatibilityType.BackwardCompatible => println("Backward compatible")
case CompatibilityType.ForwardCompatible => println("Forward compatible")
case CompatibilityType.Breaking => println("Breaking change!")- FullyCompatible: Adding optional fields only
- BackwardCompatible: Removing fields (old data works with new schema)
- ForwardCompatible: Adding required fields (new data works with old schema)
- Breaking: Type changes or mixed additions/removals
// Include $schema version (default)
val withVersion = JsonSchemaGenerator.fromSchema(schema)
// Omit $schema version
val withoutVersion = JsonSchemaGenerator.fromSchema(schema, includeSchemaVersion = false)@main def run(): Unit =
enum Color:
case Red, Green, Blue
case class Address(street: String, city: String, zipCode: String)
case class Person(
name: String,
age: Int,
isEmployed: Boolean,
address: Address,
favoriteColor: Color
) derives Schema
val schema = Schema[Person]
println(schema.jsonSchemaPretty)Produces:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" },
"isEmployed": { "type": "boolean" },
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zipCode": { "type": "string" }
},
"required": ["street", "city", "zipCode"]
},
"favoriteColor": {
"oneOf": [
{ "type": "object", "properties": { "type": { "const": "Red" } } },
{ "type": "object", "properties": { "type": { "const": "Green" } } },
{ "type": "object", "properties": { "type": { "const": "Blue" } } }
],
"discriminator": { "propertyName": "type" }
}
},
"required": ["name", "age", "isEmployed", "address", "favoriteColor"]
}The library is organized into logical packages:
schemanator.annotations- Custom JSON Schema annotations- Type-safe annotations use Scala 3 context bounds to capture schemas
- Backwards-compatible with ZIO Schema annotations
schemanator.generator- Internal conversion logic- Uses
zio-schema-jsonfor accurate encoding of complex types - Fallback mechanisms for type-erased values
- Uses
schemanator.evolution- Schema evolution trackingschemanator- Public API and extension methods
Schemanator's type-safe annotations use Scala 3's context bounds to preserve type information:
// Traditional type-erased annotation
case class examples(values: Any*) extends StaticAnnotation
// Type-safe annotation
case class exampleValues[A: Schema](values: A*) extends StaticAnnotation:
def schema: Schema[A] = summon[Schema[A]]This allows the library to:
- Capture the
Schema[A]at the annotation site - Use
zio-schema-jsonfor proper JSON encoding - Handle complex types (nested case classes, collections, etc.) correctly
- Provide compile-time type safety
# Compile for both Scala versions
sbt compile
# Run tests for both versions
sbt test
# Test specific Scala version
sbt ++2.13.16 test
sbt ++3.7.3 test
# Cross-compile and publish
sbt +publishLocalApache 2.0
Contributions are welcome! Please feel free to submit a Pull Request.