Serialization-Based Schema Generation
Table of contents
Generate JSON Schema at runtime from any @Serializable class using its SerialDescriptor.
Overview
SerializationClassJsonSchemaGenerator converts a kotlinx.serialization SerialDescriptor into a JSON Schema.
It works with any @Serializable class across all supported platforms: JVM, JS/Wasm, and Native.
Use this approach when you need schema generation at runtime without a compile-time processing step, or when integrating with existing kotlinx.serialization descriptors directly.
If you own the classes and target multiplatform, consider the KSP processor for zero-runtime-overhead compile-time generation.
Setup
Add the kt-schema-generator-json dependency to your project:
// build.gradle.kts
dependencies {
implementation("me.kpavlov.kt.schema:kt-schema-generator-json:<version>")
}No annotation processors, plugins, or additional configuration required.
Basic Usage
Define your model using a @Serializable data class:
@Serializable
@SerialName("com.example.User")
data class User(val name: String, val age: Int)Then generate the schema using the Default singleton:
val generator = SerializationClassJsonSchemaGenerator.Default
val schema = generator.generateSchema(User.serializer().descriptor)
println(schema.encodeToString(Json { prettyPrint = true }))This code prints:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.User",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer"
}
},
"additionalProperties": false,
"required": [
"name",
"age"
]
}For custom behavior, construct the generator directly with explicit introspectorConfig or jsonSchemaConfig.
@SerialName support
The generator respects @SerialName at every level โ classes, properties, and enum entries.
Use it to control the exact names that appear in the generated schema.
Renaming properties
@SerialName on a constructor parameter changes the property key in the schema’s properties object
and required array:
@Serializable
@SerialName("ApiRequest")
data class ApiRequest(
@SerialName("request_id") val requestId: String,
@SerialName("client") val userAgent: String = "unknown",
val payload: String,
)val schema = SerializationClassJsonSchemaGenerator.Default
.generateSchemaString(ApiRequest.serializer().descriptor)
schema shouldEqualJson $$"""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ApiRequest",
"type": "object",
"properties": {
"request_id": { "type": "string" },
"client": { "type": "string" },
"payload": { "type": "string" }
},
"required": ["request_id", "payload"],
"additionalProperties": false
}
""".trimIndent()The Kotlin property names (requestId, userAgent) become request_id and user_agent in the schema.
Properties with default values (userAgent) remain optional.
Renaming enum entries
@SerialName on enum entries changes the values in the schema’s enum array:
@Serializable
@SerialName("Priority")
enum class Priority {
@SerialName("p0_critical") CRITICAL,
@SerialName("p1_high") HIGH,
@SerialName("p2_medium") MEDIUM,
@SerialName("p3_low") LOW,
}val schema = SerializationClassJsonSchemaGenerator.Default
.generateSchemaString(Priority.serializer().descriptor)
schema shouldEqualJson $$"""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "Priority",
"type": "string",
"enum": ["p0_critical", "p1_high", "p2_medium", "p3_low"]
}
""".trimIndent()The reflection-based generator (ReflectionClassJsonSchemaGenerator) also respects @SerialName
on classes, properties, and enum entries โ no extra configuration needed.
Configuration
SerializationClassJsonSchemaGenerator accepts three optional constructor parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
json | Json | Json.Default | Controls JSON configuration. |
introspectorConfig | SerializationClassSchemaIntrospector.Config | Config() | Controls how descriptors are introspected. |
jsonSchemaConfig | JsonSchemaConfig | JsonSchemaConfig.Default | Controls schema output (nullability, required). |
Introspector configuration
SerializationClassSchemaIntrospector.Config controls how the generator reads descriptions from
annotations on your serializable classes and properties, and which types are treated as opaque
JSON values.
| Field | Type | Default |
|---|---|---|
descriptionExtractor | DescriptionExtractor | Reads @SerialDescription |
opaqueSerialNames | Set<String> | defaultOpaqueTypeNames() |
By default, the generator recognizes @SerialDescription with no extra setup. To read a
different annotation, supply a custom DescriptionExtractor โ see Custom description extraction
below.
@SerialDescription support
@SerialDescription is the built-in way to add descriptions to @Serializable classes.
Unlike @Description, it carries @SerialInfo so the serialization runtime preserves it in
SerialDescriptor โ no custom configuration required.
It ships in the kt-schema-generator-json module (me.kpavlov.kt.schema.generator.json package),
so you already have it when using the serialization-based generator.
Add it to your class and constructor parameters:
@Serializable
@SerialName("SuperUser")
@SerialDescription("A registered user account")
data class User(
@property:SerialDescription("The user's display name")
val name: String,
val age: Int,
)Generate the schema using Default โ descriptions appear automatically:
val schema = SerializationClassJsonSchemaGenerator.Default.generateSchemaString(User.serializer().descriptor)
println(schema)This code prints:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "SuperUser",
"description": "A registered user account",
"type": "object",
"properties": {
"name": { "type": "string", "description": "The user's display name" },
"age": { "type": "integer" }
},
"required": ["name", "age"],
"additionalProperties": false
}Use @property:SerialDescription(...) on constructor parameters to ensure the annotation
reaches the backing property โ required for the serialization runtime to include it in
SerialDescriptor.getElementAnnotations().
Custom description extraction
If your project already uses a different annotation to document properties โ for example,
a framework annotation or your own convention โ provide a DescriptionExtractor to map it to the schema description field.
DescriptionExtractor is a functional interface:
public fun interface DescriptionExtractor {
public fun extract(annotations: List<Annotation>): String?
}Example: extract descriptions from a @CustomDescription annotation.
Define your annotation and model:
@OptIn(ExperimentalSerializationApi::class)
@SerialInfo // Required: only @SerialInfo annotations are preserved in SerialDescriptor
annotation class CustomDescription(val value: String)
@Serializable
@SerialName("com.example.Person")
data class Person(
@property:CustomDescription("First name of the person")
val firstName: String,
)Then create a generator with a DescriptionExtractor that reads from @CustomDescription:
val generator = SerializationClassJsonSchemaGenerator(
introspectorConfig = SerializationClassSchemaIntrospector.Config(
descriptionExtractor = { annotations ->
annotations.filterIsInstance<CustomDescription>().firstOrNull()?.value
},
),
)
val schema: JsonSchema = generator.generateSchema(Person.serializer().descriptor)
val schemaString: String = schema.encodeToString(Json { prettyPrint = true })
println(schemaString)This code prints:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Person",
"type": "object",
"properties": {
"firstName": {
"type": "string",
"description": "First name of the person"
}
},
"additionalProperties": false,
"required": [
"firstName"
]
}The extractor receives the full annotation list for each property or class. You can combine multiple annotation sources or apply fallback logic inside the lambda.
Descriptions are propagated for all property types โ including nested objects, sealed classes,
and collections. A description annotation on a property whose type is another class will appear
in the schema alongside the $ref for that class.
For inline value classes, the class-level description annotation propagates to the flattened
primitive property in the schema. If the property itself also has a description annotation,
the property-level one takes precedence. Annotations on the inner value property are not used.
SerialDescriptor only carries annotations marked with
@SerialInfo
.
The built-in @Description from kt-schema-annotations lacks @SerialInfo and is therefore not visible here.
Use @SerialDescription
from this module instead โ it carries @SerialInfo and works with Default out of the box.
If you’re describing classes you don’t own, use the KSP processor
or
the reflection-based generator
.
Opaque JSON types
Some types represent arbitrary JSON values rather than a fixed structure โ JsonElement
and
JsonPrimitive
from kotlinx-serialization-json are the most common examples. Their
serialization descriptors use a runtime-generated format that is incompatible with the standard
sealed-type processing pipeline, so the generator maps them directly to an empty JSON Schema {}
(which accepts any value).
defaultOpaqueTypeNames() contains the built-in set:
kotlinx.serialization.json.JsonElementโ{}kotlinx.serialization.json.JsonPrimitiveโ{}kotlinx.serialization.json.JsonNullโ{}tools.jackson.databind.JsonNodeand the rest of the Jackson databind node hierarchy โ{}(concrete leaf/numeric-abstraction node types likeStringNodeandIntNodeare excluded โ they resolve to their real primitive schema instead, e.g.{"type": "string"})
JsonObject fields are handled correctly as typed maps without any special configuration โ their
value type JsonElement is already opaque, so the emitted schema is {"type": "object", "additionalProperties": {}}.
Adding custom opaque types: if you have a type whose descriptor causes processing errors
(e.g., a custom sealed class with a non-standard runtime descriptor), add its serial name to
opaqueSerialNames:
val generator = SerializationClassJsonSchemaGenerator(
introspectorConfig = SerializationClassSchemaIntrospector.Config(
opaqueSerialNames = defaultOpaqueTypeNames() + setOf("com.example.RawJson"),
),
)JSON Schema output configuration
Pass a JsonSchemaConfig to control how nullable types and required fields appear in the output:
@Serializable
@SerialName("com.example.Person")
data class Person(
val firstName: String,
)
val generator = SerializationClassJsonSchemaGenerator(
jsonSchemaConfig = JsonSchemaConfig.Strict,
)
val schema: JsonSchema = generator.generateSchema(Person.serializer().descriptor)
val schemaString: String = schema.encodeToString(Json { prettyPrint = true })
println(schemaString)This code generates:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Person",
"type": "object",
"properties": {
"firstName": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"firstName"
]
}JsonSchemaConfig presets
| Preset | Description |
|---|---|
Default | Respects default values; nullable fields use union types ["string", "null"]. |
Strict | All fields required (including nullable); union types. Use for OpenAI strict function calling. |
OpenAPI | Nullable fields use "nullable": true; includes discriminator for polymorphic types. |
Lenient | Compact and permissive: only non-nullable fields without defaults are required, no null markers, extra properties allowed. Short $defs names do not apply to this generator. |
JsonSchemaConfig reference
| Property | Type | Default | Description |
|---|---|---|---|
respectDefaultPresence | Boolean | true | Mark fields with default values as optional (requires reflection; KSP tracks presence but not values). |
requireNullableFields | Boolean | false | Include nullable fields in the required array. |
useUnionTypes | Boolean | true | Represent nullable types as ["string", "null"] (Draft 2020-12). |
useNullableField | Boolean | false | Emit "nullable": true instead of union types (legacy OpenAPI compatibility). |
includePolymorphicDiscriminator | Boolean | true | Add a "type" property with a constant discriminator value to each polymorphic subtype schema. |
includeOpenAPIPolymorphicDiscriminator | Boolean | false | Include a discriminator mapping object in oneOf schemas (OpenAPI 3.x). Requires includePolymorphicDiscriminator. |
allowAdditionalProperties | Boolean | false | Omit additionalProperties from object schemas (extra properties allowed) instead of emitting false. Maps are unaffected. |
shortDefinitionNames | Boolean | false | Use the shortest unique dotted suffix of the qualified name for $defs keys, $refs, the root $id and discriminator values. Annotation names are kept; kotlinx.serialization schemas are never shortened. |
useUnionTypes and useNullableField are mutually exclusive. With both false, nullable properties carry no null
marker and are never required (an absent field means null); nullable collection elements and map values get an
anyOf null branch.
Polymorphic types
Both sealed and open polymorphic hierarchies are supported. By default, the generator reads the
discriminator name from Json.classDiscriminator and produces oneOf schemas with $defs for
each subtype. If a polymorphic base class is annotated with @JsonClassDiscriminator("..."),
that class-level annotation takes precedence over the global Json.classDiscriminator value for
that specific sealed or polymorphic base.
Sealed polymorphism
For sealed classes, the compiler embeds all subtypes in the SerialDescriptor โ no SerializersModule needed:
@Serializable
@SerialName("com.example.Shape")
sealed class Shape {
@Serializable
@SerialName("com.example.Shape.Circle")
data class Circle(val radius: Double) : Shape()
@Serializable
@SerialName("com.example.Shape.Rectangle")
data class Rectangle(val width: Double, val height: Double) : Shape()
}val generator = SerializationClassJsonSchemaGenerator(
json = Json { classDiscriminator = "type" }
)
val schema: JsonSchema = generator.generateSchema(Shape.serializer().descriptor)
val schemaString: String = schema.encodeToString(Json { prettyPrint = true })
println(schemaString)Each subtype gets a required discriminator property containing the subtype’s serial name as a constant:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "com.example.Shape",
"type": "object",
"oneOf": [
{
"$ref": "#/$defs/com.example.Shape.Circle"
},
{
"$ref": "#/$defs/com.example.Shape.Rectangle"
}
],
"$defs": {
"com.example.Shape.Circle": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "com.example.Shape.Circle"
},
"radius": {
"type": "number"
}
},
"required": [
"type",
"radius"
],
"additionalProperties": false
},
"com.example.Shape.Rectangle": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "com.example.Shape.Rectangle"
},
"width": {
"type": "number"
},
"height": {
"type": "number"
}
},
"required": [
"type",
"width",
"height"
],
"additionalProperties": false
}
}
}Excluding subtypes with @SerialSchemaIgnore
Sealed hierarchies sometimes contain subtypes that shouldn’t appear in the schema โ internal sentinels,
testing stubs, or deprecated variants. Annotate them with @SerialSchemaIgnore to exclude them from
the generated oneOf composition and $defs:
@Serializable
@SerialName("com.example.Event")
sealed class Event {
@Serializable
@SerialName("com.example.Event.Click")
data class Click(val x: Int, val y: Int) : Event()
@Serializable
@SerialName("com.example.Event.Internal")
@SerialSchemaIgnore
data class Internal(val trace: String) : Event()
}The Internal subtype compiles and serializes normally โ only schema generation skips it.
@SerialSchemaIgnore carries @SerialInfo so it’s preserved in SerialDescriptor.
The plain @SchemaIgnore annotation (from kt-schema-annotations) works with KSP and
reflection generators but is not visible through SerialDescriptor.
Open polymorphism
For abstract classes and interfaces where subtypes aren’t known at compile time, register them
in a SerializersModule and pass it via the Json configuration. Only the registered subtypes
appear in the generated schema:
@Serializable
@SerialName("com.example.Flying")
abstract class Flying {
abstract val name: String
}
@Serializable
@SerialName("com.example.Bird")
data class Bird(override val name: String, val wingspan: Double) : Flying()
@Serializable
@SerialName("com.example.Kite")
data class Kite(override val name: String, val material: String) : Flying()val module = SerializersModule {
polymorphic(Flying::class) {
subclass(Bird::class)
subclass(Kite::class)
}
}
val generator = SerializationClassJsonSchemaGenerator(
json = Json { serializersModule = module }
)
val schema = generator.generateSchemaString(
PolymorphicSerializer(Flying::class).descriptor
)
println(schema)The output follows the same oneOf / $defs structure as sealed classes. Register only a subset
of subtypes to produce a schema limited to those types.
Use PolymorphicSerializer(Base::class).descriptor as the entry point for open polymorphic
types โ Base.serializer() is not available for non-sealed abstract classes.
See Also
- KSP Processor โ Compile-time schema generation with zero runtime overhead
- Annotation Reference
โ
@Schema,@Description, and@SerialDescriptionusage - Multi-Framework Annotation Support โ Recognize Jackson, LangChain4j, and other annotations
- Runtime Schema Generation โ Reflection-based alternative for third-party classes
- JSON Schema DSL โ Manual schema construction