@niscorp/solid — Design
How the library works, what decisions were made, and why.
The problem
LLMs that produce structured output stream JSON token by token. Before the last token arrives, the JSON is incomplete — you can't JSON.parse it. But you want to show fields to the user as they appear, react to subtrees as they finalize, and never expose an invalid state.
The naive solution is to repair the partial JSON (close brackets, trim dangling tokens) and re-parse the entire buffer on every chunk. This works but scales quadratically — every write re-scans everything that came before it.
Solid takes a different approach.
Core idea
The streamed JSON is not the state. The state is a valid object that exists from the start.
const stream = createStream({ schema, initial });
initial is validated against the Zod schema at construction. Every current() call returns an object — before, during, and after streaming — and a value of the wrong JSON kind is never written into it (see "The always-valid invariant" for where that check applies). The incoming JSON progressively overwrites fields in this object as they're parsed; an object the reply creates (a list row, an object where initial has null or nothing, a record entry) starts empty and fills in key by key.
This means consumers can render and act on the state immediately. There is no "waiting for valid JSON" phase.
Architecture
write(chunk)
│
▼
┌─────────────────────────┐
│ Incremental parser │ O(chunk) — processes only new characters
│ ├─ Scanner state │ tracks strings, escapes, containers, paths
│ ├─ Value-open hook ──────┼──→ Validator (kind check → accept | skip)
│ ├─ Skip-mode │ consumes rejected subtrees without writing
│ ├─ Value extraction │ writes accepted values into mutable root
│ ├─ Dirty tracking │ records which paths were modified
│ └─ Event emission │ structural events for finalization
└────────────┬────────────┘
│
┌───────┴───────┐
│ │
▼ ▼
┌──────────┐ ┌──────────────────┐
│ Snapshot │ │ Finalization │
│ engine │ │ tracker │
│ │ │ │
│ Structural│ │ Marks paths as │
│ sharing │ │ final based on │
│ from prev │ │ parser events │
│ snapshot │ │ │
└─────┬─────┘ └───────┬─────────┘
│ │
▼ ▼
┌──────────────────────────────┐
│ Validator (finalize phase) │ opt-in: safeParse per field at close
│ └─ reads keys from tracker │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Stream (root + selections) │
│ ├─ on() — reference === │
│ ├─ onFinal() │
│ ├─ onError() — path-scoped │
│ ├─ select() — cached │
│ └─ destroy() │
└──────────────────────────────┘
Files
| File | Responsibility |
|---|---|
incremental-parser.ts |
Scanner, value extraction, dirty tracking, structural sharing snapshots, value-open hook, skip-mode |
finalization-tracker.ts |
Determines when paths become final based on parser events |
schema-walker.ts |
Given a Zod schema and path, returns sub-schema + accepted JSON kinds |
validator.ts |
Enforces the invariant: kind checks at value-open, sub-schema safeParse at finalize |
create-stream.ts |
Root stream factory, listener management, write pipeline, error/failed-state plumbing |
selected-stream.ts |
Projected subtree streams with independent subscriptions, path-scoped onError |
types.ts |
Public and internal types, FinalState factory |
path.ts |
Path splitting, traversal, projection |
derive-defaults.ts |
Derives valid base objects from Zod schema defaults |
Key decisions
1. Incremental parsing, not repair+parse
Decision: Build a custom incremental JSON parser instead of repairing partial JSON and calling JSON.parse.
Why: The repair+parse approach re-scans the entire accumulated buffer on every write(). Over N total characters in ~N/100 chunks, total work is O(N²). For a 20 KB response that's measurably slow (~38ms). The incremental parser maintains scanner state between calls and processes only new characters — O(N) total.
Trade-off: We own the parser. If a JSON edge case breaks, it's our bug. We mitigate this with thorough tests (192 unit/integration tests + e2e streaming tests).
Result: 44x faster for character-by-character streaming of a 10 KB payload.
2. Structural sharing, not deep cloning
Decision: Produce immutable snapshots using copy-on-write structural sharing instead of structuredClone.
Why: The parser mutates an internal object. Consumers need immutable values (so stored references don't change under them). structuredClone copies the entire object tree — O(object_size) per write. Structural sharing only creates new objects along dirty paths; everything else keeps the same reference from the previous snapshot.
How it works:
- The parser tracks which paths were modified during a
write()via adirtyParentsmap (parent path → set of changed child keys). - The
snapshot(previous)method walks the tree. At each node, if no children are dirty, it returns thepreviousreference directly. If children are dirty, it creates a new object with the dirty children replaced and clean children shared. - After the snapshot, the dirty map is cleared.
Result: O(dirty_paths × depth) per snapshot instead of O(object_size). For a write that changes one string field at depth 3, that's ~3 object spreads instead of cloning a 20-field object tree.
3. Reference equality, not deep comparison
Decision: Selected streams use === for change detection instead of deepEqual.
Why: Structural sharing guarantees that unchanged subtrees keep the same object reference. If snapshot.widget === previousSnapshot.widget, the widget didn't change. This makes change detection O(1) instead of O(subtree_size).
Consequence: select('widget').on(...) only fires when the widget subtree actually changed. It does NOT fire when sibling fields (like response) change — even though the root object is new, the widget reference is shared.
4. Partial string visibility
Decision: String values are visible character by character as they stream in, not only when the closing quote arrives.
Why: LLM responses often contain long text fields. Waiting for the closing quote means the user sees nothing until the entire field is complete. By setting the partial string value on every character, on(...) subscribers see the text grow in real time.
How: The parser calls setValue(valueBuffer) on every non-escape character while inside a string. The dirty tracking and snapshot machinery handle the rest.
5. Skip-if-unchanged for setValue
Decision: The parser checks value !== existing before marking a path dirty.
Why: Without this, writing {"type":""} when the base already has type: "" would create a new snapshot (dirty flag set), which would trigger notifications and create new object references — even though nothing actually changed. The !== check prevents this.
For primitives: === catches identity. "card" !== "" is true (changed). "" !== "" is false (skip).
For containers: New objects/arrays created by enterContainer always have a different reference, so !== always holds. Correct — a new container IS a change.
6. Left-to-right finalization
Decision: Subtree finalization is based on parser position, not value completeness.
Why: JSON objects are ordered. When the parser sees key "response" at root depth, it knows "widget" can never receive more data — the producer has moved on. This is a structural guarantee, not a heuristic.
Assumption: Producers emit keys in a deliberate order. LLM structured output does this naturally — the model writes fields left to right. Consumers should place immediately useful fields first (UI/render → text → reasoning → metadata).
Implementation: The finalization tracker maintains a keysPerContainer map. When a new key enters, all previous sibling keys (and their descendants) are finalized. When a container closes, all its children are finalized.
7. Events decouple parsing from finalization
Decision: The parser emits ParserEvent objects that the finalization tracker consumes, rather than the tracker being embedded in the parser.
Why: Separation of concerns. The parser knows about JSON syntax. The tracker knows about path finalization semantics. Neither needs to know about the other's internals. Events are the interface.
Event types: enterObject, leaveObject, enterArray, leaveArray, enterKey, enterIndex, valueComplete.
8. Selected streams are projections, not copies
Decision: select('widget') returns a lightweight wrapper that projects from the root state, not an independent stream with its own parse buffer.
Why: All parsing happens once at root. Selected streams are just lenses into the root state. They subscribe to root change signals (value-free — no type casting needed), project their subtree via getByPath, and compare with the previous reference.
Caching: select('widget') always returns the same stream instance. The cache is a Map<string, Stream> on the root.
9. Factory functions, not classes
Decision: Follow the codebase style guide — factory functions returning explicit interfaces.
Why: Closures for private state, explicit return objects for public surface. No this binding issues. Testable, composable, tree-shakeable.
10. Element-level array merge
Decision: Array elements are merged by index, not replaced wholesale.
Why: With wholesale replacement, select('items.0') would see a "change" on every write that touches the array — because the array reference is new even if index 0 didn't change. Element-level merge means each index is tracked independently. The finalization tracker treats array indices like object keys.
Performance characteristics
Per-write cost
| Operation | Complexity |
|---|---|
| Parser scan | O(chunk_length) |
| Dirty tracking | O(depth) per value set |
| Snapshot | O(dirty_paths × depth) |
| Finalization check | O(events) |
| Selected stream notify | O(1) per selection (reference ===) |
Total per-write: O(chunk_length + dirty_count × depth)
Scaling
| Payload | Chunks (50B) | Total time |
|---|---|---|
| 1 KB | 21 | 0.4ms |
| 5 KB | 105 | 1.0ms |
| 10 KB | 210 | 3.2ms |
| 20 KB | 419 | 12ms |
Scaling ratio (20KB/1KB ms/KB): 1.4x — effectively linear.
The always-valid invariant
Solid's central promise: outside trust mode, where the schema states a value's JSON kind, a value of another kind is never written into what current() returns. Not "will be right eventually" — right now, and after every write, regardless of what the LLM sends. It is a promise about kinds, not completeness: an object the reply creates fills in key by key (see Consequences).
The problem it solves
Without validation during streaming, a hallucinated field quietly corrupts the tree. Schema says count: number, LLM emits "count": "three", consumer does x.count + 1 and gets "three1". Schema says items: T[], LLM emits "items": "oops", consumer does items.map(...) and crashes. TypeScript said the type was a number, the object says it's a string, and nobody noticed.
Solid enforces the invariant at two phases:
Phase 1 — value-open kind check (always on)
The moment the parser sees the first character of a value ({, [, ", digit, t/f/n), it knows the value's JSON kind. It calls a hook into the validator with (path, kind). The validator walks the schema tree from the root to that path via schema-walker.ts and returns accept or skip.
On skip, the parser enters skip-mode: it consumes characters without writing values, without emitting events, without marking anything dirty. Skip-mode is bounded by matched quotes (strings, with escape awareness), matched braces/brackets (objects/arrays with depth counting), or literal terminators. Once the value closes, normal parsing resumes with the sibling or container close.
This is O(1) per value — one path lookup, one Set.has. No safeParse, no allocation, no hot-path cost.
Phase 2 — finalize constraints (opt-in)
Kind checks catch type-shape mismatches. They don't catch .min(5) on a string, .int() on a number, .email(), .regex(...), or .refine(...). Those require the complete value. Running them on a partial string would produce false positives — a 3-character string in flight isn't yet the 20-character string it will be when the closing quote arrives.
With constraints: 'finalize', the validator runs subSchema.safeParse(currentValue) exactly once per field, at the moment that field finalizes (the tracker-reported structural close). Partial strings never trip constraints they're about to satisfy, because the check only fires after the final character has been written.
Modes
Three modes govern what happens on a kind violation:
trust— no validation. Today's pre-invariant behavior. Included as a debug escape hatch; discouraged in production.recover(default) — the bad value is skipped, the prior valid value stays in place, an error is emitted viaonError, and the stream continues. One hallucinated field doesn't tank the rest of the response.strict— the bad value is skipped, an error is emitted once, the stream enters a terminal failed state.current()freezes at the last valid snapshot, further writes are no-ops,final()rejects. For when rendering wrong is worse than rendering nothing.
A constraint violation (phase 2) is found after the value has been written, so it is never skipped: in recover the value stays in current() and final() and the error is emitted; in strict the stream fails the same way, frozen with the violating value in it. trust runs neither phase.
Consequences
- A value of the wrong JSON kind is never written into
current()(inrecoverandstrict, for the schema kinds the walker checks — see Sub-schema walking), and every fieldinitialholds keeps a value. - An object the reply creates starts empty and fills in key by key. Until it closes,
current()can lack keys the schema requires, and no error is raised for that; a complete reply that never sends a required key of such an object leavesfinal()without it (reported only underconstraints: 'finalize'). current()is not always constraint-valid, mid-stream or at the end.constraints: 'finalize'reports a violation when the field closes; it does not take the value back.- No throwing from
write(). All error surfaces go throughonError+final()rejection. Signal adapters and other producer loops can write without try/catch. - Selected streams see only errors at-or-below their path.
select('widget').onError(...)ignores errors on sibling fields.
Sub-schema walking
schema-walker.ts walks the top-level Zod schema along a path:
ZodObject→shape[key]ZodArray→elementZodTuple→items[idx]orrestZodRecord→valueTypeZodUnion→ first variant that accepts the segmentZodOptional/ZodNullable/ZodDefault→ unwrap and recurseZodLiteral,ZodEnum,ZodString/Number/Boolean/Null→ leaf kinds- Discriminated unions → object kind; full narrowing deferred to finalize
ZodLazy/ZodIntersection/ZodPipeline/ZodAny/ZodUnknown→ accept-any (kind check skipped; finalize still runs if enabled)
The walker caches nothing; the lookup is O(depth) per value. For typical schemas (depth 3–5), that's a few property accesses per write.
What this library is not
- Not a generic JSON parser. It's purpose-built for schema-backed LLM streaming.
- Not a diff engine. It doesn't compute patches or deltas. It tracks dirty paths for structural sharing.
- Not a recovery tool for malformed JSON. If the LLM sends syntactically broken JSON (unmatched braces, bad escapes), the parser ignores the unparseable tokens and keeps the last valid state. It doesn't guess or repair JSON — it only rejects schema mismatches at value-open.
- Not a coercion engine. If you want
z.coerce.number().parse("3")behavior, put it in your schema. Solid doesn't transform values.
Public API
type Stream<T> = {
write: (chunk: string) => void;
close: () => void;
destroy: () => void;
current: () => T;
final: () => Promise<T>;
on: (listener: (value: T) => void) => () => void;
onFinal: (listener: (value: T) => void) => () => void;
onError: (listener: (error: StreamError) => void) => () => void;
select: <P = unknown>(path: string) => Stream<P>;
};
type StreamError = {
path: string;
expected: string;
received: string;
phase: 'value-open' | 'finalize';
message: string;
};
const createStream: <T>(options: {
schema: z.ZodType<T>;
initial?: T;
mode?: 'trust' | 'recover' | 'strict'; // default: 'recover'
constraints?: 'kind' | 'finalize'; // default: 'kind'
}) => Stream<T>;
Nine methods. That's the whole product.
Known limitations
Path separator
All path operations use . as the separator. Keys containing literal dots (e.g. "foo.bar") will be split and misinterpreted. In practice this never arises — LLM structured output schemas use simple identifier keys — but there is no escaping mechanism if it does.
select('') observes all errors
select('') returns a stream that projects the entire root. Its onError listener fires for errors at every path, since all paths are at-or-below the root. This is consistent with the semantics (root selection = whole tree) but may surprise consumers who expect only root-level errors. Use select('specific.path') for scoped error observation.