Skip to content

Tapir Schema Derivation

Drop-in replacement for Tapir's built-in Schema.derived -- derives Schema[A] for case classes, sealed traits, Scala 3 enums, Java enums, and more.

Installation

sbt

libraryDependencies += "com.kubuszok" %% "kindlings-tapir-schema-derivation" % "0.2.0"

Cross-platform (JVM / Scala.js / Scala Native):

libraryDependencies += "com.kubuszok" %%% "kindlings-tapir-schema-derivation" % "0.2.0"

You also need tapir-core:

libraryDependencies += "com.softwaremill.sttp.tapir" %%% "tapir-core" % "1.13.19"

Scala CLI

//> using dep com.kubuszok::kindlings-tapir-schema-derivation:0.2.0
//> using dep com.kubuszok::kindlings-circe-derivation:0.2.0
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.19

Quick start

Deriving Schema for a case class
//> using scala 2.13.18
//> using dep com.kubuszok::kindlings-tapir-schema-derivation:0.2.0
//> using dep com.kubuszok::kindlings-circe-derivation:0.2.0
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.19

import hearth.kindlings.tapirschemaderivation._
import hearth.kindlings.circederivation._
import sttp.tapir.Schema

case class Person(name: String, age: Int)

// Semi-automatic
val schema: Schema[Person] = KindlingsSchema.derived[Person].schema
println(schema.name)
// expected output:
// Some(SName(Person,List()))

API

Derivation methods

Method Returns Description
KindlingsSchema.derived[A] Schema[A] Semi-automatic schema derivation
KindlingsSchema.derived[A] KindlingsSchema[A] Sanely-automatic (given/implicit)

Unlike other Kindlings modules, Tapir Schema derivation takes no configuration parameter. Instead, it automatically discovers JSON configuration from sibling modules (see below).

Automatic JSON configuration discovery

When kindlings-circe-derivation or kindlings-jsoniter-derivation is on the classpath, KindlingsSchema reads their configuration at compile time and applies the same field name transforms. This means your schema matches your codecs by design — the API documentation (OpenAPI / Swagger) always reflects the actual JSON payloads, without maintaining a separate configuration.

For example, if your Circe Configuration uses withSnakeCaseMemberNames, the generated Tapir schema will also use snake_case field names — no additional configuration needed, no risk of drift.

Disambiguating multiple JSON configs

If both Circe and Jsoniter configurations are on the classpath, the macro finds multiple configs and fails. Import an implicit PreferSchemaConfig to tell the macro which one to use:

import hearth.kindlings.circederivation.Configuration
import hearth.kindlings.tapirschemaderivation._
import hearth.kindlings.circederivation._

implicit val preferCirce: PreferSchemaConfig[Configuration] = PreferSchemaConfig[Configuration]

Tapir annotations

KindlingsSchema supports Tapir's standard annotations from sttp.tapir.Schema.annotations:

Annotation Target Description
@description("text") Type, Field Add OpenAPI description
@title("text") Type Set the schema title
@encodedName("name") Field Override the encoded field name
@format("fmt") Field Set the schema format (e.g. "int32", "email")
@validate(validator) Field Add a field validator
@default(value) Field Set the default value
@deprecated Type Mark schema as deprecated
@hidden Field Hide field from the schema
import sttp.tapir.Schema.annotations._
import sttp.tapir.Validator

@description("A user account")
@title("User")
case class User(
  @description("The username") name: String,
  @format("int32") @validate(Validator.min(0)) age: Int,
  @hidden internalId: Long
)

Usage examples

Sealed trait schema
//> using scala 2.13.18
//> using dep com.kubuszok::kindlings-tapir-schema-derivation:0.2.0
//> using dep com.kubuszok::kindlings-circe-derivation:0.2.0
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.19

import hearth.kindlings.tapirschemaderivation._
import hearth.kindlings.circederivation._
import sttp.tapir.Schema

sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape

val schema: Schema[Shape] = KindlingsSchema.derived[Shape].schema
println(schema.name)
// Some(SName(Shape,List()))
Schema with Tapir annotations
//> using scala 2.13.18
//> using dep com.kubuszok::kindlings-tapir-schema-derivation:0.2.0
//> using dep com.kubuszok::kindlings-circe-derivation:0.2.0
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.19

import hearth.kindlings.tapirschemaderivation._
import hearth.kindlings.circederivation._
import sttp.tapir.Schema
import sttp.tapir.Schema.annotations._
import sttp.tapir.Validator

@description("A person with metadata")
@title("PersonMeta")
case class AnnotatedPerson(
  @description("The name") name: String,
  @format("int32") age: Int
)

val schema: Schema[AnnotatedPerson] = KindlingsSchema.derived[AnnotatedPerson].schema
println(schema.description)
// Some(A person with metadata)
Schema with JSON config discovery
import hearth.kindlings.circederivation._
import hearth.kindlings.tapirschemaderivation._
import sttp.tapir.Schema

// Circe configuration with snake_case
implicit val circeConfig: Configuration = Configuration.default
  .withSnakeCaseMemberNames

case class UserProfile(firstName: String, lastName: String)

// Schema automatically picks up snake_case from Circe config
val schema: Schema[UserProfile] = KindlingsSchema.derived[UserProfile].schema
// Field names: first_name, last_name (matching JSON encoding)
Recursive data types
//> using scala 2.13.18
//> using dep com.kubuszok::kindlings-tapir-schema-derivation:0.2.0
//> using dep com.kubuszok::kindlings-circe-derivation:0.2.0
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.19

import hearth.kindlings.tapirschemaderivation._
import hearth.kindlings.circederivation._
import sttp.tapir.Schema

case class TreeNode(value: Int, children: List[TreeNode])

// Recursive types work out of the box — uses schema references
val schema: Schema[TreeNode] = KindlingsSchema.derived[TreeNode].schema
Generic types
//> using scala 2.13.18
//> using dep com.kubuszok::kindlings-tapir-schema-derivation:0.2.0
//> using dep com.kubuszok::kindlings-circe-derivation:0.2.0
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.13.19

import hearth.kindlings.tapirschemaderivation._
import hearth.kindlings.circederivation._
import sttp.tapir.Schema

case class Box[A](value: A)
case class Person(name: String, age: Int)

implicit val personSchema: Schema[Person] = KindlingsSchema.derived[Person].schema
println(personSchema.name)
// Some(SName(Person,List()))

// Box[Person] picks up the Person schema via implicit
lazy val boxSchema: Schema[Box[Person]] = KindlingsSchema.derived[Box[Person]].schema
println(boxSchema.name)
// Some(SName(Box,List(Person)))

Debugging

Import the debug package to log the derivation process at compile time:

import hearth.kindlings.tapirschemaderivation.debug._

This enables the LogDerivation implicit for KindlingsSchema, printing the derivation steps to the compiler output.

Comparison with Tapir built-in

Feature differences

Feature Tapir built-in (Schema.derived) Kindlings
Scala 2.13 support No (Scala 3 Mirrors only) Yes
Same API on Scala 2.13 and 3 No Yes
Sanely-automatic derivation No Yes
Recursive types Manual implicit lazy val Just works
Named tuples No Yes
Opaque types No Yes
Java enums Partial Yes
Automatic JSON config discovery No Yes
Tapir annotation support Yes Yes
Value types (AnyVal) Yes Yes

Benchmarks

Tapir Schema derivation generates a runtime Schema[A] value. At runtime, accessing the schema is just a field read, so there is no meaningful performance difference between implementations:

Benchmark Kindlings Tapir built-in
Schema field access ~3B ops/s ~3B ops/s

The performance is identical because both approaches produce the same runtime representation -- the work is done entirely at compile time.