Skip to content

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 is Partial.done(a) with last.value == Some(a) — equal to what extract would 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 typed ExtractionError an extraction would produce. Partials emitted before the failure remain valid observations.

v1 limits

  • Transport-agnostic — no live SSE transports yet. StreamingExtract.stream consumes 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 extract on failure.
  • fs2 first. The CE/fs2 surface ships today; ZIO and sync surfaces follow the same PartialDecoder core (in typecast-core, dependency-free) — build on typecast.stream.PartialDecoder directly if you need a custom surface.