Make LLM output a compile-time concern.
TypeCast is a JVM library that turns untrusted LLM text into validated, typed values: schema derivation from your domain types, schema-aligned repair, constraint-aware retry, and streaming partial decode. One library owns the trust boundary between any LLM provider and your program — for Scala 3 & Java on the JVM, JDK 17+.
your domain type ──derive──▶ schema ──▶ provider ──▶ raw text
│
typed value ◀── validate ◀── repair ◀── parse ◀──────┘
▲ │
└──── targeted retry ◀─────┘ (on failure, with precise errors)
Declare your domain type, derive the schema, extract. No hand-written JSON Schema, no prompt
begging for JSON, no JsonNode:
import java.time.LocalDate
import typecast.*
import typecast.anthropic.Anthropic
case class LineItem(description: String, quantity: Int, amount: BigDecimal) derives LlmSchema
case class Invoice(
@desc("legal name of the issuing company") vendor: String,
invoiceNumber: String,
invoiceDate: LocalDate,
dueDate: Option[LocalDate], // optional field, not a null union
total: BigDecimal,
lineItems: List[LineItem]
) derives LlmSchema
val tc = TypeCast(Anthropic.fromEnv(), model = "claude-haiku-4-5")
tc.extract[Invoice](emailBody) match
case Right(inv) =>
println(s"${inv.vendor}: ${inv.total} due ${inv.dueDate.getOrElse("on receipt")}")
case Left(err) =>
println(err.render) // JSONPath-addressed, human-readableextract[A] returns a validated A or a typed failure explaining exactly what went wrong —
never a half-parsed value. BigDecimal, java.time types, nested lists, and optional fields all
decode losslessly; provider-native structured output is used automatically when available.
Discriminated unions are the universal structured-output pain point. Here the sealed trait is the model's decision space, and the handling code is exhaustivity-checked by the compiler:
import io.github.iltotore.iron.:|
import io.github.iltotore.iron.constraint.numeric.Positive
import typecast.*
import typecast.iron.given
enum Team derives LlmSchema:
case Billing, Technical, Legal
sealed trait SupportAction derives LlmSchema
case class Refund(orderId: String, amount: BigDecimal :| Positive) extends SupportAction
case class Escalate(team: Team, reason: String) extends SupportAction
case class Reply(message: String) extends SupportAction
tc.extract[SupportAction](ticketText).map {
case Refund(id, amt) => payments.refund(id, amt)
case Escalate(team, r) => queue.escalate(team, r)
case Reply(msg) => mailer.send(msg)
}Add case class CloseTicket(ticketId: String) extends SupportAction and recompile:
[error] match may not be exhaustive.
[error] It would fail on pattern case: CloseTicket(_)
The model's output space and the handling code cannot silently diverge. And that
BigDecimal :| Positive refinement is at once a schema constraint on the wire, a validator, and
— when the model violates it — a targeted reprompt addressed at exactly the failing field.
- Repair before retry. Deterministic repair is free; retries cost tokens. The tolerant
parser strips code fences, removes trailing commas, completes truncations; alignment
fuzzy-matches keys and enum values and applies unambiguous coercions (
"67"→67) — all before a single reprompt is spent, and all recorded in the report. - Targeted reprompts. A validation failure becomes a reprompt that pins the already-valid
fields and asks the model to fix only the failing paths (
.diagnosisCodes[1]: "j449" violates Pattern(...)), with optional model escalation and hard token budgets. - Capability lowering + post-hoc backstop. Each provider declares what it can enforce natively; the schema is lowered to that subset and every constraint that can't travel on the wire is enforced by validation instead, feeding the same retry loop. User code never changes across providers. See docs/providers-and-lowering.md.
- Streaming partial decode.
Partial[A]values as tokens arrive: each already-complete field is already validated, and the final element is guaranteed identical to the strictextract[A]result. See docs/streaming.md. - Tools → MCP from the same
derivesclause.derives LlmToolturns a case class into a tool definition; malformed tool arguments get the same repair/retry treatment as extractions;McpServer.from(tools).serveStdio()exports the registry as an MCP server. See docs/tools-and-mcp.md. - First-class Java API. Records, sealed interfaces, and Jakarta Bean Validation annotations
(
@Positive,@Pattern,@Email) drive the same constraint → validate → targeted-retry loop. See docs/java.md. - Every extraction observable.
extractWithReportreturns anExtractionReport— attempts, repairs, alignments, errors, token usage — the seam for evals and CI gates.
Same user code on every provider; only the factory changes:
| Provider | Factory | Mode |
|---|---|---|
| Anthropic | typecast.anthropic.Anthropic.fromEnv() (reads ANTHROPIC_API_KEY) or AnthropicTransport(apiKey) |
Native structured outputs |
| OpenAI | typecast.openai.OpenAi(apiKey) |
Native strict mode (schema lowered to the strict subset) |
| Ollama (local) | typecast.openai.Ollama() — OpenAI-compatible endpoint at localhost:11434/v1 |
JSON mode + repair |
| vLLM, Groq, any OpenAI-compatible | typecast.openai.OpenAiCompatible.jsonMode(baseUri) / .promptOnly(baseUri) |
JSON mode / prompt-only + repair |
| LangChain4j (20+ providers) | typecast-langchain4j adapter (JsonMode + prompt-embedded schema) |
whatever the ChatModel supports |
val anthropic = TypeCast(Anthropic.fromEnv(), model = "claude-haiku-4-5")
val openai = TypeCast(OpenAi(sys.env("OPENAI_API_KEY")), model = "gpt-4o-mini")
val local = TypeCast(Ollama(), model = "llama3.2")The model identifier is required at construction — model names are provider-scoped and there is no sensible cross-provider default.
Not yet on Maven Central; coordinates below are the planned shape (organization io.typecast,
version 0.1.0-SNAPSHOT until the first tag). The core is zero-dependency (Scala stdlib
only).
sbt:
libraryDependencies ++= Seq(
"io.typecast" %% "typecast-core" % "0.1.0-SNAPSHOT",
"io.typecast" %% "typecast-anthropic" % "0.1.0-SNAPSHOT", // or -openai
"io.typecast" %% "typecast-iron" % "0.1.0-SNAPSHOT" // optional: refinement constraints
)Gradle:
implementation("io.typecast:typecast-core_3:0.1.0-SNAPSHOT")
implementation("io.typecast:typecast-anthropic_3:0.1.0-SNAPSHOT")
implementation("io.typecast:typecast-java_3:0.1.0-SNAPSHOT") // Java facadeMaven:
<dependency>
<groupId>io.typecast</groupId>
<artifactId>typecast-core_3</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>Modules: typecast-core (pipeline, derivation, zero deps) · typecast-anthropic /
typecast-openai (transports) · typecast-iron (Iron refinements) · typecast-tools +
typecast-mcp (tool dispatch, MCP export) · typecast-runner-ce / -zio / -sync (effect
runners; -ce carries fs2 streaming) · typecast-java (Java facade) · typecast-langchain4j
(adapter).
Plain Markdown in docs/ — no site generator in v1, deliberately: every Scala snippet
in this README and in SKILL.md is mirrored as compiled (and mostly executed) test
code under modules/examples/src/test/scala/typecast/examples/docs/, so the docs cannot drift
from the API silently.
- Getting started — full walkthrough
- Structured output — derivation,
@desc, unions, constraints - Retry & repair — the pipeline, report transcripts
- Streaming —
Partial[A]semantics - Tools & MCP
- Java
- Providers & lowering · lowering matrix
- Benchmarks
Agent-first assets: llms.txt (index for AI agents) and SKILL.md (recipes for coding agents using TypeCast in your project).
Complete runnable apps under examples-realworld/, each with its own README, scripted tests, and live local-model runs:
- invoice-pipeline (Scala) — batch email→ledger ingestion: iron-refined domain, cross-field semantic validation feeding targeted retries, failure quarantine with full report transcripts as the audit trail, cost summary.
- spring-triage (Java/Spring Boot) — a triage microservice: Jakarta-constrained sealed-interface decisions,
@ConfigurationPropertieswiring, structured 422s instead of 500s on extraction failure. - review-classifier (Scala/cats-effect) — nightly batch classification with real concurrency (
parEvalMapUnordered), per-item budgets, and cost/latency accounting. - kotlin-triage (Kotlin/Gradle, standalone) — consumes the published artifacts from a Gradle build:
@JvmRecord+ sealed interfaces, exhaustivewhenrouting, and the@field:constraint-target gotcha, pinned by tests.
Pre-1.0 and honest about it:
- The API surface is small (
extract/extractWithReport/stream/ tools) but may still move before 1.0. - Not yet published to Maven Central;
TypeCastitself is a working name and may change before launch. - Streaming is transport-agnostic in v1: you feed it provider text deltas; live SSE transports layer on top later.
- Published benchmark numbers are in progress (the harness is in
typecast-bench; see docs/benchmarks.md). - Scala 3.7+, JDK 17+. No Scala 2 support, by design.
License: Apache-2.0.