Skip to content

Repository files navigation

TypeCast

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)

Sixty seconds

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-readable

extract[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.

The flagship: sealed traits as the model's decision space

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.

What you get

  • 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 strict extract[A] result. See docs/streaming.md.
  • Tools → MCP from the same derives clause. derives LlmTool turns 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. extractWithReport returns an ExtractionReport — attempts, repairs, alignments, errors, token usage — the seam for evals and CI gates.

Providers

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.

Install

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 facade

Maven:

<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).

Documentation

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.

Agent-first assets: llms.txt (index for AI agents) and SKILL.md (recipes for coding agents using TypeCast in your project).

Real-world examples

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, @ConfigurationProperties wiring, 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, exhaustive when routing, and the @field: constraint-target gotcha, pinned by tests.

Status

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; TypeCast itself 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.

About

Make LLM output a compile-time concern: schema derivation, repair, and constraint-aware retry for Scala 3 & Java on the JVM.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages