๐Ÿ˜ธ kt-schema

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.

tip

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:

kotlin
// 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:

kotlin
@Serializable
@SerialName("com.example.User")
data class User(val name: String, val age: Int)

Then generate the schema using the Default singleton:

kotlin
val generator = SerializationClassJsonSchemaGenerator.Default
val schema = generator.generateSchema(User.serializer().descriptor)
println(schema.encodeToString(Json { prettyPrint = true }))

This code prints:

json
{
    "$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:

kotlin
@Serializable
@SerialName("ApiRequest")
data class ApiRequest(
    @SerialName("request_id") val requestId: String,
    @SerialName("client") val userAgent: String = "unknown",
    val payload: String,
)
kotlin
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:

kotlin
@Serializable
@SerialName("Priority")
enum class Priority {
    @SerialName("p0_critical") CRITICAL,
    @SerialName("p1_high") HIGH,
    @SerialName("p2_medium") MEDIUM,
    @SerialName("p3_low") LOW,
}
kotlin
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()
tip

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:

ParameterTypeDefaultDescription
jsonJsonJson.DefaultControls JSON configuration.
introspectorConfigSerializationClassSchemaIntrospector.ConfigConfig()Controls how descriptors are introspected.
jsonSchemaConfigJsonSchemaConfigJsonSchemaConfig.DefaultControls 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.

FieldTypeDefault
descriptionExtractorDescriptionExtractorReads @SerialDescription
opaqueSerialNamesSet<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:

kotlin
@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:

kotlin
val schema = SerializationClassJsonSchemaGenerator.Default.generateSchemaString(User.serializer().descriptor)
println(schema)

This code prints:

json
{
  "$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
}
important

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:

kotlin
public fun interface DescriptionExtractor {
    public fun extract(annotations: List<Annotation>): String?
}

Example: extract descriptions from a @CustomDescription annotation.

Define your annotation and model:

kotlin
@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:

kotlin
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:

json
{
    "$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"
    ]
}
tip

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.

note

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.

note

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.

warning

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:

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:

kotlin
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:

kotlin
@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:

json
{
    "$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

PresetDescription
DefaultRespects default values; nullable fields use union types ["string", "null"].
StrictAll fields required (including nullable); union types. Use for OpenAI strict function calling.
OpenAPINullable fields use "nullable": true; includes discriminator for polymorphic types.
LenientCompact 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

PropertyTypeDefaultDescription
respectDefaultPresenceBooleantrueMark fields with default values as optional (requires reflection; KSP tracks presence but not values).
requireNullableFieldsBooleanfalseInclude nullable fields in the required array.
useUnionTypesBooleantrueRepresent nullable types as ["string", "null"] (Draft 2020-12).
useNullableFieldBooleanfalseEmit "nullable": true instead of union types (legacy OpenAPI compatibility).
includePolymorphicDiscriminatorBooleantrueAdd a "type" property with a constant discriminator value to each polymorphic subtype schema.
includeOpenAPIPolymorphicDiscriminatorBooleanfalseInclude a discriminator mapping object in oneOf schemas (OpenAPI 3.x). Requires includePolymorphicDiscriminator.
allowAdditionalPropertiesBooleanfalseOmit additionalProperties from object schemas (extra properties allowed) instead of emitting false. Maps are unaffected.
shortDefinitionNamesBooleanfalseUse 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.
note

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:

kotlin
@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()
}
kotlin
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:

json
{
    "$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:

kotlin
@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.

note

@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:

kotlin
@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()
kotlin
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.

tip

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