Design notes
A structured-output guarantee library for TypeScript/JS. Define the output shape with Zod; the LLM always returns schema-conformant data.
1. Background and problem
LLM output is free-form text, and developers face one core pain: after every call they write a pile of parsing, validation, and fallback code.
// Typical post-processing today
const raw = await llm.chat(...)
const json = JSON.parse(raw) // may throw
if (!json.name) throw ... // manual per-field checks
if (typeof json.age !== 'number') ... // defensive code everywhere
The Python ecosystem's answer: Instructor (10k+ stars), built on Pydantic — one line guarantees schema-conformant output.
The TypeScript ecosystem: had no equivalent. zodstructor fills that gap.
2. Target users
TypeScript developers using LLMs in Node.js, Bun, or Edge runtimes.
3. Core design principles
| Principle | Meaning |
|---|---|
| Zero required deps (except zod) | LLM SDKs install on demand — no forced 300MB dependency tree |
| Provider-agnostic | One API adapts OpenAI / Anthropic / Google / any OpenAI-compatible endpoint |
| Schema-first | Define the shape with Zod; the library handles conversion, injection, validation, retry |
| Automatic retry | On validation failure, feed the errors back to the LLM so it repairs itself |
| Streaming compatible | Collect the full JSON, validate once; optional partialSchema for best-effort prefix checks |
4. API design
4.1 Basic usage
import { z } from 'zod'
import { createZodstructor, OpenAIProvider } from 'zodstructor'
// 1. Define the schema
const UserSchema = z.object({
name: z.string(),
age: z.number().int().min(0),
email: z.string().email(),
tags: z.array(z.string()),
})
// 2. Create the client
const client = createZodstructor(new OpenAIProvider({ apiKey: '...' }))
// 3. Call — output is guaranteed to match UserSchema
const user = await client.completion({
schema: UserSchema,
messages: [
{ role: 'user', content: 'Extract: Ada, 36, ada@example.com, tags: admin, security' }
],
model: 'gpt-4o-mini',
})
// user's type is inferred as { name: string; age: number; email: string; tags: string[] }
4.2 Streaming usage
import { streamStructured } from 'zodstructor'
const stream = streamStructured({
provider: new OpenAIProvider({ apiKey: '...' }),
schema: UserSchema,
messages: [{ role: 'user', content: '...' }],
})
for await (const chunk of stream) {
process.stdout.write(chunk) // raw JSON text, chunk by chunk
}
const result = await stream.return()
console.log(result.value) // validated complete object
4.3 Switching providers
// Only the provider changes; everything else stays
const client = createZodstructor(new AnthropicProvider({ apiKey: '...' }))
// or
const client = createZodstructor(new GoogleProvider({ apiKey: '...' }))
// or any OpenAI-compatible endpoint
const client = createZodstructor(new OpenAIProvider({
apiKey: '...', baseURL: 'https://api.deepseek.com/v1'
}))
4.4 Automatic retry & error feedback
const result = await client.completion({
schema: z.object({ score: z.number().min(0).max(100) }),
messages: [{ role: 'user', content: '...' }],
maxRetries: 3, // up to 3 retries (default)
onRetry: (attempt, error) => { // called on every retry
console.log(`retry ${attempt}: ${error.message}`)
},
})
5. Architecture
src/
├── index.ts # unified exports
├── core/
│ ├── types.ts # all type definitions
│ ├── completion.ts # Zodstructor main class + createZodstructor
│ └── retry.ts # retry engine (core logic)
├── providers/
│ ├── base.ts # abstract BaseProvider
│ ├── openai.ts # OpenAI / OpenAI-compatible
│ ├── anthropic.ts # Anthropic (Claude)
│ ├── google.ts # Google Gemini
│ └── index.ts
├── streaming/
│ └── index.ts # stream collection + deferred validation
└── utils/
└── schema.ts # Zod → JSON Schema + JSON extraction
6. Core flow
6.1 Retry engine flow
input: schema + messages + provider
│
├─ 1. Zod → JSON Schema conversion
├─ 2. inject schema into system prompt
│
├─ 3. call LLM → raw text
├─ 4. extractJson() → pure JSON
├─ 5. JSON.parse()
├─ 6. schema.safeParse()
│
├─ pass → return typed data
│
└─ fail
├─ attempts left
│ ├─ append assistant message (the invalid output)
│ ├─ append user message (error description + required schema)
│ └─ back to step 3
│
└─ attempts exhausted → throw ValidationError
6.2 Schema conversion coverage
| Zod type | JSON Schema |
|---|---|
z.string() | { type: "string" } |
z.number() / .int() | { type: "number" } / { type: "integer" } |
z.boolean() | { type: "boolean" } |
z.array(T) | { type: "array", items: T } |
z.object({...}) | { type: "object", properties, required } |
z.enum([...]) / z.literal(V) | { enum: [...] } / { enum: [V] } |
z.union([...]) | { anyOf: [...] } |
optional/default/catch | generic: not in required; strict: in required as anyOf [T, null] |
z.nullable(T) | generic: nullable: true; strict: anyOf [T, null] |
z.tuple([...]) | prefixItems + minItems/maxItems |
z.record(V) | { type: "object", additionalProperties: V } |
z.intersection(A, B) | { allOf: [A, B] } |
z.discriminatedUnion(...) | { oneOf: [...] } |
z.date() | { type: "string", format: "date-time" } |
.describe('...') | description: "..." |
.min(N) / .max(N) | minimum / maxLength etc. |
7. Provider interface
interface LLMProvider {
name: string
complete(params: CompletionParams): Promise<string>
stream?(params: CompletionParams): AsyncIterable<string>
}
interface CompletionParams {
messages: ChatMessage[]
model?: string
schema: JsonSchema // JSON Schema for injection
schemaName?: string
maxTokens?: number
temperature?: number
mode?: OutputMode // auto | json_schema | json_object | tool_call | prompt
structured?: boolean // legacy API; true ≡ json_schema
}
Provider strategies
| Provider | Structured-output strategy |
|---|---|
| OpenAI chat.completions | response_format: json_schema, or tools + tool_choice for tool_call |
| OpenAI Responses | text.format: json_schema/json_object, or read output[*].function_call.arguments for tool_call |
| Anthropic | tools + tool_choice reading tool_use.input; otherwise system-prompt injection + raw-JSON instruction |
responseMimeType: "application/json" + responseSchema | |
| OpenAI-compatible | response_format: { type: "json_object" } + prompt injection |
8. Dependency design
dependencies: zod (only)
peerDependencies: openai (optional) | @anthropic-ai/sdk (optional) | @google/generative-ai (optional)
devDependencies: tsup, typescript, vitest
Users install only the provider SDKs they actually use.
9. Comparison with Instructor (Python)
| Feature | Instructor (Python) | zodstructor |
|---|---|---|
| Schema definition | Pydantic | Zod |
| Language | Python | TypeScript |
| Retry mechanism | Yes | Yes |
| Streaming | Yes | Yes |
| Multi-provider | Yes | Yes |
| Native JSON Schema | OpenAI only | OpenAI + Google |
| Peer deps strategy | On demand | On demand |
| Bundle size | ~50KB | target <30KB |
10. Release plan
- v0.1.0 — core: Zod→JSON Schema, OpenAI provider, auto retry, unit tests
- v0.2.0 — Anthropic + Google providers, streaming
- v0.3.0 — OpenAI-compatible auto-discovery, more schema types
- v1.0.0 — stable API, complete docs, production ready