Streaming partial decode
typecast-runner-ce turns a stream of raw text chunks into a stream of progressively-populated,
validated Partial[A] snapshots — typed optimistic UI without hand-parsing half-broken JSON
fragments.
import cats.effect.IO
import fs2.Stream
import typecast.*
import typecast.runnerce.stream.StreamingExtract
import typecast.stream.Partial
case class Competitor(name: String, strength: String, weakness: String) derives LlmSchema
case class MarketReport(
summary: String,
competitors: List[Competitor],
risks: List[String],
recommendation: String
) derives LlmSchema
// chunks: Stream[IO, String] — e.g. your provider's SSE text deltas
val partials: Stream[IO, Partial[MarketReport]] =
StreamingExtract.stream[IO, MarketReport](chunks)
The summary fills in, competitors appear one by one — and each value you can observe has already been validated.
Partial[A] semantics
A Partial[A] is a tolerant-parse snapshot of the accumulated buffer plus the set of paths that
may still change — the rightmost spine. Because the buffer grows strictly by appending, the
only values that can still change are on the path from the root to the last parsed token (the
last field of each object, the last element of each array). Everything off that spine is
syntactically closed and therefore final.
p.isDone // the document has closed
p.at(path): Option[Js] // raw subtree, even if still in flight
p.isComplete(path) // present AND off the rightmost spine
p.get[B](path): Option[B] // typed: Some iff complete + validates + decodes
p.narrow[B](path): Option[Partial[B]] // typed cursor into a nested in-flight value
p.value: Option[A] // whole-document view; Some only once done
get is the contract in miniature: a value becomes observable only when it is complete, passes
constraint validation against its schema, and strict-decodes. Values that would need
alignment (coercion, key fuzzing) stay None mid-stream and surface on the terminal element
only — alignment is not stable on prefixes.
The typed fields view
With typecast-derivation's named-tuple view (import typecast.partial.*), one-level access is
statically typed — options fill, lists grow:
import typecast.partial.*
partials.map { p =>
val f = p.fields
// f.summary: Option[String] — Some once complete + validated
// f.competitors: List[Competitor] — the as-built prefix of complete elements
// f.risks: List[String]
// f.recommendation: Option[String]
render(f.summary, f.competitors)
}
Descend into in-flight nested values with narrow; there are no derived mirror types and no
recursion limits.
Emission and terminal guarantees
- One emission per observable change. Consecutive duplicate snapshots are never emitted; bytes accumulating inside one in-flight string don't produce emissions.
- Mid-stream garbage never fails the stream. Anything the tolerant parser can repair is carried; a momentarily unparseable buffer just skips that chunk.
- The final element IS the strict result. When the chunk stream ends, the full accumulated
text runs through the same strict pipeline as
TypeCast.extract(parse → align → validate → strict decode → semantic validators). On success the last element isPartial.done(a)withlast.value == Some(a)— equal to whatextractwould have returned on the same text, by construction. Semantic validators and alignment run on this terminal element only. - Failures are typed. If the final document doesn't validate (or no JSON could be recovered),
the stream fails with
StreamingExtract.Failure(error)carrying the same typedExtractionErroran extraction would produce. Partials emitted before the failure remain valid observations.
v1 limits
- Transport-agnostic — no live SSE transports yet.
StreamingExtract.streamconsumes an already-produced chunk stream; wiring a provider's streaming endpoint into it is your code (and live streaming transports are planned to layer exactly here). The bundled provider transports are synchronous in v1 (Capabilities.streaming = false). - No retry inside a stream. The terminal strict pipeline validates; it does not reprompt.
If you need retry semantics, fall back to
extracton failure. - fs2 first. The CE/fs2 surface ships today; ZIO and sync surfaces follow the same
PartialDecodercore (intypecast-core, dependency-free) — build ontypecast.stream.PartialDecoderdirectly if you need a custom surface.