Skip to content

Structured output: derivation, unions, constraints

Everything TypeCast knows about a type lives in one typeclass:

trait LlmSchema[A]:
  def schema: SchemaDoc                                  // provider-agnostic schema tree
  def decode(js: Js): Either[Vector[FieldError], A]     // STRICT structural decode
  def encode(a: A): Js                                   // few-shot examples, golden sets
  def validators: Vector[SemanticValidator[A]]           // user semantic checks

Instances come from derives LlmSchema, the built-in primitive givens, or your own definitions. decode is deliberately strict: coercions and fuzzy matching happen in the alignment stage of the pipeline (and are recorded in the report), never silently inside a decoder.

What derivation supports

import typecast.*

case class Order(
    id: String,
    items: List[Item],            // List / Vector / Seq / Set -> JSON array
    metadata: Map[String, String],// Map[String, V] -> JSON object (string keys only)
    placed: java.time.Instant,    // ISO-8601 date-time
    delivery: Option[LocalDate],  // optional field (omitted from required)
    total: BigDecimal             // exact decimal, no double rounding
) derives LlmSchema
Scala type Schema Notes
case class object, additionalProperties: false field defaults make fields optional on the wire
sealed trait / Scala 3 enum with cases tagged union discriminator field, default "type"
parameterless enum closed string enum fuzzy-matched at alignment ("billing"Billing)
Option[A] optional field not a null union
List / Vector / Seq / Set array Set deduplicates on decode
Map[String, V] string-keyed object non-String keys are a compile error
String, Boolean, Int, Long, Double, BigDecimal primitives built-in givens
LocalDate / Instant / Duration string + format strict ISO-8601
opaque types underlying type provide a one-line given (below)
recursive types $ref into definitions simple type names must be unique

Unsupported shapes fail at compile time with a descriptive error, not at runtime.

Opaque types

An opaque type is its underlying type at runtime; tell TypeCast how it travels:

opaque type OrderId = String
object OrderId:
  def apply(value: String): OrderId = value
  given LlmSchema[OrderId] = LlmSchema[String]

Annotations: @desc and @discriminator

case class Invoice(
    @desc("legal name of the issuing company") vendor: String,
    total: BigDecimal
) derives LlmSchema

@discriminator("kind")
sealed trait Event derives LlmSchema
  • @desc on a field or type becomes the schema description — prompt material the model actually sees; use it wherever a name alone is ambiguous.
  • @discriminator overrides the union tag field (default "type").

Unions: sealed traits and enums

sealed trait SupportAction derives LlmSchema
case class Refund(orderId: String, amount: BigDecimal) extends SupportAction
case class Escalate(team: Team, reason: String)        extends SupportAction
case class Reply(message: String)                      extends SupportAction

The union travels to the provider as one discriminated schema (oneOf/anyOf on the wire, a compact Refund | Escalate | Reply notation in prompt mode). On the way back, alignment fuzzy-matches the tag ("refund"Refund) and can even infer an omitted tag when the argument shape uniquely identifies one variant. Your match over the result is exhaustivity-checked — extending the trait without handling the new case is a compile error.

Constraints: one IR, two front-ends

Constraints live in a small internal IR (MinNumber, MaxNumber, MultipleOf, MinLength, MaxLength, Pattern, Format, MinItems, MaxItems, UniqueItems). Each constraint drives three things at once:

  1. a JSON Schema keyword on the wire — for providers that enforce it natively,
  2. post-hoc validation — for providers that don't (see providers-and-lowering.md),
  3. the targeted retry message when violated (.age: 200 violates MaxNumber(130)).

Scala front-end: Iron refinements (typecast-iron)

import io.github.iltotore.iron.:|
import io.github.iltotore.iron.constraint.numeric.{Interval, Positive}
import io.github.iltotore.iron.constraint.string.Match
import typecast.*
import typecast.iron.{ValidEmail, given}

case class Patient(
    name: String,
    age: Int :| Interval.Closed[0, 130],
    email: String :| ValidEmail,
    diagnosisCodes: List[String :| Match["[A-Z][0-9]{2}\\.[0-9]"]]
) derives LlmSchema

import typecast.iron.given brings the LlmSchema instance for refined types plus the constraint lowerings:

Iron constraint IR JSON Schema
Positive (= Greater[0]) MinNumber(0, exclusive) exclusiveMinimum: 0
Negative (= Less[0]) MaxNumber(0, exclusive) exclusiveMaximum: 0
Greater[V] / Less[V] exclusive min/max exclusiveMinimum / exclusiveMaximum
GreaterEqual[V] / LessEqual[V] inclusive min/max minimum / maximum
Interval.Closed[L, H] MinNumber(L) + MaxNumber(H) minimum + maximum
MinLength[N] / MaxLength[N] MinLength(N) / MaxLength(N) minLength / maxLength
Match["regex"] Pattern(regex) pattern
Email (alias ValidEmail, defined by typecast-iron) Format(Email) format: "email"

Iron aliases built from these (via DescribedAs and intersections) lower transparently. The refinement is also enforced on the Scala side — a Patient with age = 200 is unrepresentable.

Java front-end: Jakarta Bean Validation

@Min, @Max, @Positive, @DecimalMin, @Size, @Pattern, @Email, @NotBlank, @NotEmpty on record components lower to the same IR — see java.md for the full mapping table.

Semantic validators

For checks beyond the constraint vocabulary, attach a validator to any instance; failures are JSONPath-addressed and feed the same targeted-retry loop:

given LlmSchema[Invoice] = LlmSchema.derived[Invoice].withValidation("items-sum-to-total") { inv =>
  if inv.lineItems.map(_.amount).sum == inv.total then Vector.empty
  else Vector(FieldError(JsonPath.root / "total", "total does not equal the sum of line items"))
}