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
@descon a field or type becomes the schemadescription— prompt material the model actually sees; use it wherever a name alone is ambiguous.@discriminatoroverrides 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:
- a JSON Schema keyword on the wire — for providers that enforce it natively,
- post-hoc validation — for providers that don't (see providers-and-lowering.md),
- 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: