Skip to content

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 a ModelParams for temperature / max output tokens: TypeCast(transport, ModelParams("llama3.2", temperature = Some(0.0))).
  • Anthropic.fromEnv() returns an Either. A missing ANTHROPIC_API_KEY is not thrown: it is deferred, and every extraction reports it as ExtractionError.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). Use RetryPolicy.none to fail fast, RetryPolicy.targeted(n) for a different cap.
  • escalateTo switches to a (usually stronger) model after escalateAfter failed attempts — cheap model first, escalate once.
  • Budget is a hard ceiling per extraction; the session fails with BudgetExhausted rather 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