Getting started
TypeCast turns untrusted LLM text into validated, typed values. This walkthrough takes you from an empty project to a configured extraction with retries, budgets, and an observable report.
Requirements: JDK 17+, Scala 3.7+ (or Java — see java.md).
1. Add the dependencies
libraryDependencies ++= Seq(
"io.typecast" %% "typecast-core" % "0.1.0-SNAPSHOT",
"io.typecast" %% "typecast-anthropic" % "0.1.0-SNAPSHOT" // or typecast-openai
)
typecast-core has zero external dependencies (Scala stdlib only) and contains the whole
pipeline: JSON AST, tolerant parser, schema derivation, alignment, validation, the extraction
state machine, and report rendering. Provider modules add only an HTTP client.
2. Declare a domain type
import java.time.LocalDate
import typecast.*
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],
total: BigDecimal,
lineItems: List[LineItem]
) derives LlmSchema
derives LlmSchema gives you a provider-ready schema, a strict decoder, and an encoder — no
hand-written JSON Schema. @desc text travels to the provider as schema descriptions. Option
fields become optional fields (omitted from required), not null unions. See
structured-output.md for everything derivation supports.
3. Construct a TypeCast and extract
import typecast.anthropic.Anthropic
val tc = TypeCast(Anthropic.fromEnv(), model = "claude-haiku-4-5")
tc.extract[Invoice](emailBody) match
case Right(inv) => println(inv.total)
case Left(err) => println(err.render)
Things to know:
- The model is required at construction. Model names are provider-scoped
(
"claude-haiku-4-5","gpt-4o-mini","llama3.2"), so there is no cross-provider default. Pass aModelParamsfor temperature / max output tokens:TypeCast(transport, ModelParams("llama3.2", temperature = Some(0.0))). Anthropic.fromEnv()returns anEither. A missingANTHROPIC_API_KEYis not thrown: it is deferred, and every extraction reports it asExtractionError.TransportFailed. You can also construct transports directly:AnthropicTransport(apiKey),OpenAi(apiKey),Ollama().- Instances are immutable and as thread-safe as their transport. Configuration methods
(
withRetry,withBudget,withParams) are copy-style and return new instances.
4. Handle failure as data
extract[A] returns Either[ExtractionError, A] — a validated value or a typed failure, never
a half-parsed value. The error cases:
| Error | Meaning |
|---|---|
ParseFailed |
no JSON could be recovered from the response, even with repair |
ValidationFailed |
the (repaired, aligned) JSON violates the schema or your validators; carries JSONPath-addressed FieldErrors |
BudgetExhausted |
the retry/budget caps were hit before a valid value was produced |
TransportFailed |
HTTP/auth/rate-limit failure, carried as a typed TransportError |
Every error carries the full ExtractionReport (err.report) and renders human-readable
(err.render), e.g. .amount: -5 violates MinNumberExclusive(0).
5. Observe what happened
import typecast.report.* // brings the report.render extension
val (result, report) = tc.extractWithReport[Invoice](emailBody)
println(report.render)
attempt 1 model=claude-haiku-4-5 1,204 tokens 0.9s
repairs: extracted content of markdown code fence
aligned: .age: string "67" -> number 67
FAILED validation:
.diagnosisCodes[1]: "j449" violates Pattern([A-Z][0-9]{2}\.[0-9])
attempt 2 targeted reprompt (1 error, valid fields pinned) 312 tokens 0.6s
OK
total: 1,516 tokens, $0.0026, 1 retry
The report is the eval seam: attempts, repairs, alignments, errors, and token usage as plain data — assert on it in CI, log it, diff it. See retry-and-repair.md.
6. Configure retries and budgets
val tuned = TypeCast(Anthropic.fromEnv(), model = "claude-haiku-4-5")
.withRetry(RetryPolicy(maxAttempts = 3, escalateTo = Some("claude-sonnet-4-5"), escalateAfter = 2))
.withBudget(Budget(maxTotalTokens = Some(20000L)))
- The default policy is
RetryPolicy.targeted(three attempts, targeted reprompts). UseRetryPolicy.noneto fail fast,RetryPolicy.targeted(n)for a different cap. escalateToswitches to a (usually stronger) model afterescalateAfterfailed attempts — cheap model first, escalate once.Budgetis a hard ceiling per extraction; the session fails withBudgetExhaustedrather than exceed it.
7. Swap providers without touching user code
import typecast.openai.{OpenAi, Ollama, OpenAiCompatible}
val openai = TypeCast(OpenAi(sys.env("OPENAI_API_KEY")), model = "gpt-4o-mini")
val local = TypeCast(Ollama(), model = "llama3.2") // localhost:11434, JSON mode
val groq = TypeCast(
OpenAiCompatible.jsonMode("https://api.groq.com/openai/v1", apiKey = sys.env.get("GROQ_API_KEY")),
model = "llama-3.3-70b-versatile"
)
Each provider declares what it can enforce natively; TypeCast lowers your schema to that subset and enforces the rest post-hoc by validation. Your extraction code is identical everywhere — see providers-and-lowering.md.
Where next
- structured-output.md — unions, enums, constraints (Iron & Jakarta)
- retry-and-repair.md — what each pipeline stage does
- streaming.md — progressive
Partial[A]decode - tools-and-mcp.md — typed tool dispatch and MCP export
- java.md — the Java facade
- SKILL.md — copy-paste recipes (also written for coding agents)