FAQ & troubleshooting
Questions and gotchas that come up building and running TypeCast. Each answer links to the page that covers the topic in full.
Construction & configuration
Why do I have to pass a model name — isn't there a default?
The model identifier is required at construction: TypeCast(transport, model = "claude-haiku-4-5").
Model names are provider-scoped ("claude-haiku-4-5", "gpt-4o-mini", "llama3.2"), so there is
no sensible cross-provider default to fall back to. Pass temperature or max output tokens with a
ModelParams instead of the bare string: TypeCast(transport, ModelParams("llama3.2", temperature = Some(0.0))).
See getting-started.md.
Anthropic.fromEnv() didn't throw when my API key was missing — why does extraction fail later?
Anthropic.fromEnv() returns an Either[TransportError, SyncTransport], and TypeCast accepts
that Either directly, so a missing ANTHROPIC_API_KEY is deferred rather than thrown: it
surfaces as ExtractionError.TransportFailed on the first extract call. This keeps construction
total and pushes the failure into the same typed-error channel as everything else. To fail at
construction instead, build the transport directly (AnthropicTransport(apiKey), OpenAi(apiKey)).
Are TypeCast instances safe to reuse and share?
Yes. Instances are immutable and as thread-safe as their underlying transport. The configuration
methods — withRetry, withBudget, withParams, withTools — are copy-style and return new
instances rather than mutating in place, so a configured TypeCast can be built once and shared.
Providers
How do I choose a provider?
Your extraction code is identical on every provider; only the factory changes, and what changes
underneath is how much of your schema the provider enforces natively. Use typecast.anthropic.Anthropic
or typecast.openai.OpenAi for hosted models with native structured output; typecast.openai.Ollama()
for a local OpenAI-compatible endpoint at localhost:11434; and OpenAiCompatible.jsonMode(baseUri)
/ .promptOnly(baseUri) for vLLM, Groq, or anything else speaking the Chat Completions protocol.
There is no separate Ollama module — local and OpenAI-compatible servers all go through the
typecast-openai factories. See providers-and-lowering.md.
A constraint isn't being enforced on the wire — is it being dropped?
No. Each transport declares a SchemaSubset of keywords it can enforce natively; every constraint
that can't travel on the wire is stripped from the wire schema but stays in the IR and is enforced
by validation after decode, feeding the same targeted-retry loop. For example, OpenAI strict mode
rejects minimum, so age: Int :| Interval.Closed[0, 130] comes back as .age: 200 violates MaxNumber(130)
plus a targeted reprompt rather than as a wire-level rejection. The cell-by-cell behaviour is in
lowering-matrix.md.
Ollama returns HTTP 400 about a regex pattern — what's happening?
Ollama's json_schema/GBNF converter rejects unanchored regex patterns (it requires patterns to
start with ^ and end with $). To avoid that failure, Ollama's declared schema subset marks
pattern as unsupported, so it is lowered off the wire and enforced by the validate-and-retry
backstop post-hoc instead. This is the same lowering mechanism as any other unenforceable
constraint — the pattern still holds, it just moves from decode-time to validation. See the
write-up in benchmarks/2026-06-12-ollama.md.
My local model passes everything on the "raw" tier — is repair/retry doing nothing?
When a provider enforces the schema server-side (Ollama applies grammar-constrained decoding to the lowered wire schema), every response is already syntactically valid JSON in the right shape, so the raw-parse baseline is already as strong as the full pipeline and repair has nothing to fix. Worse, native constrained decoding can mask value errors: the model emits values that satisfy the constraints but are wrong (an in-range but incorrect number, transposed digits in a code), and because those values validate, the targeted-retry loop is never triggered. Measuring repair/retry lift requires a provider or mode without server-side enforcement; the 2026-06-12 Ollama run (findings 1–2) shows exactly this effect.
Java & Kotlin
My Jakarta constraint annotation is being ignored from Kotlin — why?
Kotlin's default annotation use-site target is the constructor parameter only, which record
reflection never surfaces — so a plain @Positive val amount: BigDecimal is silently ignored.
Write the constraint with an explicit use-site target, @field:Positive (or @get:), so it lands
on the backing field that TypeCast actually reads:
@JvmRecord
data class Refund(
val orderId: String,
@field:Positive val amount: BigDecimal, // NOT plain `@Positive val amount`
) : SupportAction
TypeCast reads component, accessor, and backing-field annotations, so @field:/@get: works while
the bare form does not. See java.md and the kotlin-triage example, which pins this
end-to-end with a test.
Why does my Gradle/Maven dependency need a _3 suffix?
typecast-java_3 carries Scala's binary-compatibility suffix (_3 for Scala 3), the same
convention as cats-core_3. sbt appends it automatically via %%; from Gradle or Maven you spell
it out (io.typecast:typecast-java_3:0.1.0-SNAPSHOT). The suffix says nothing about consumability —
the jars are ordinary JVM jars and the Java/Kotlin-facing surface keeps Scala types out of its
signatures.
Retries, budgets & escalation
How do retries and budgets actually work?
The pipeline always exhausts deterministic repair (parse + alignment) before spending a reprompt;
only a validation failure becomes a retry. The default RetryPolicy.targeted makes three attempts
with targeted reprompts — each retry message contains only the addressed errors and pins the
already-valid fields, asking the model to fix only the failing paths. RetryPolicy.targeted(n)
changes the attempt cap and RetryPolicy.none fails fast. A Budget(maxTotalTokens = Some(...))
is a hard per-extraction ceiling: the session fails with BudgetExhausted rather than exceed it.
See retry-and-repair.md.
What does model escalation do?
RetryPolicy(maxAttempts, escalateTo = Some("..."), escalateAfter = n) switches to a different
(usually stronger) model after n failed attempts — the idea is cheap model first, escalate once.
In the report transcript the escalated attempt names the new model, so you can see exactly when the
switch happened.
What are the typed failure modes of extract?
extract[A] returns Either[ExtractionError, A] — a validated value or a typed failure, never a
half-parsed value. The cases are ParseFailed (no JSON recoverable even after repair),
ValidationFailed (repaired/aligned JSON violates the schema or your validators; carries
JSONPath-addressed FieldErrors), BudgetExhausted (caps hit before a valid value), and
TransportFailed (HTTP/auth/rate-limit failure carrying a typed TransportError). Every error
carries the full ExtractionReport (err.report) and renders human-readable via err.render.
Reports
How do I read an ExtractionReport?
Call extractWithReport, which always returns the report (on failure the same report rides on the
error), then render it as an attempt-by-attempt transcript with import typecast.report.* and
report.render:
import typecast.report.*
val (result, report) = tc.extractWithReport[Patient](clinicalNote)
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
Each attempt records the model used, repairs (stage 1), JSONPath-addressed alignments (stage 2),
and validation errors (stage 3). The transcript is just rendering — the underlying data is plain
case classes (report.attemptCount, report.totalUsage, report.attempts.head.repairs /
.alignments / .errors), which is the seam for evals and CI gates. Costs come from a
caller-overridable PricingTable (unpriced models omit the cost), and durations appear only when a
runner injects them — core has no clock. See retry-and-repair.md.
report.render doesn't resolve — what am I missing?
render is an extension method, not a TypeCast member; bring it into scope with
import typecast.report.*. The analogous import gotchas: withTools needs import typecast.tools.*,
and refined fields need import typecast.iron.given (with typecast-iron on the classpath).
Streaming
Does TypeCast connect to a provider's streaming endpoint for me?
Not in v1. StreamingExtract.stream (in typecast-runner-ce) consumes an already-produced
Stream[IO, String] of text chunks — wiring a provider's streaming/SSE endpoint into that stream is
your code, and live streaming transports are planned to layer exactly there. The bundled provider
transports are synchronous in v1 (Capabilities.streaming = false). See streaming.md.
Why are some streamed fields missing until the stream ends?
A mid-stream value becomes observable through get only when it is complete (off the rightmost
spine), passes constraint validation against its schema, and strict-decodes. Values that would
need alignment (coercion, key fuzzing) deliberately stay None mid-stream and surface on the
terminal element only, because alignment is not stable on prefixes. The stream's last element runs
the same strict pipeline as extract, so last.value equals the strict result by construction (or
the stream fails with StreamingExtract.Failure(error) carrying the same typed ExtractionError).
There is no retry inside a stream — fall back to extract if you need reprompting.